From aa1b03c928dff072e734cce9579b1ca9692b2af5 Mon Sep 17 00:00:00 2001 From: Joby Wilson Mathews Date: Thu, 21 Aug 2025 18:38:33 +0530 Subject: [PATCH 01/12] Initial commit of openapi-mcp-server --- src/openapi-mcp-server/.gitignore | 38 ++ src/openapi-mcp-server/pom.xml | 84 ++++ .../oracle/mcp/openapi/OpenApiMcpServer.java | 164 +++++++ .../openapi/cache/McpServerCacheService.java | 32 ++ .../config/OpenApiMcpServerConfiguration.java | 61 +++ .../openapi/enums/OpenApiSchemaAuthType.java | 15 + .../enums/OpenApiSchemaSourceType.java | 17 + .../mcp/openapi/enums/OpenApiSchemaType.java | 46 ++ .../mcp/openapi/fetcher/AuthConfig.java | 13 + .../oracle/mcp/openapi/fetcher/AuthType.java | 8 + .../openapi/fetcher/OpenApiSchemaFetcher.java | 76 ++++ .../fetcher/RestApiExecutionService.java | 98 +++++ .../mcp/openapi/model/McpServerConfig.java | 126 ++++++ .../tool/OpenApiToMcpToolConverter.java | 412 ++++++++++++++++++ .../src/main/resources/application.properties | 1 + .../src/main/resources/logback.xml | 35 ++ 16 files changed, 1226 insertions(+) create mode 100644 src/openapi-mcp-server/.gitignore create mode 100644 src/openapi-mcp-server/pom.xml create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/cache/McpServerCacheService.java create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaAuthType.java create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaSourceType.java create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaType.java create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/AuthConfig.java create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/AuthType.java create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/RestApiExecutionService.java create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/McpServerConfig.java create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiToMcpToolConverter.java create mode 100644 src/openapi-mcp-server/src/main/resources/application.properties create mode 100644 src/openapi-mcp-server/src/main/resources/logback.xml diff --git a/src/openapi-mcp-server/.gitignore b/src/openapi-mcp-server/.gitignore new file mode 100644 index 00000000..5ff6309b --- /dev/null +++ b/src/openapi-mcp-server/.gitignore @@ -0,0 +1,38 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/src/openapi-mcp-server/pom.xml b/src/openapi-mcp-server/pom.xml new file mode 100644 index 00000000..8b942ba9 --- /dev/null +++ b/src/openapi-mcp-server/pom.xml @@ -0,0 +1,84 @@ + + + 4.0.0 + com.oracle.mcp.openapi + openapi-mcp-server + 1.0-SNAPSHOT + jar + + + 21 + 21 + UTF-8 + 3.3.0 + + + + + + org.springframework.boot + spring-boot-starter + ${spring.boot.version} + + + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + 2.19.2 + + + + + io.modelcontextprotocol.sdk + mcp + 0.11.1 + + + + + io.swagger.parser.v3 + swagger-parser + 2.1.31 + + + + org.slf4j + slf4j-api + 2.0.7 + + + + + ch.qos.logback + logback-classic + 1.5.18 + compile + + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + + + + repackage + + + + + + + + + \ No newline at end of file diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java new file mode 100644 index 00000000..31094214 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java @@ -0,0 +1,164 @@ +package com.oracle.mcp.openapi; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.oracle.mcp.openapi.cache.McpServerCacheService; +import com.oracle.mcp.openapi.enums.OpenApiSchemaAuthType; +import com.oracle.mcp.openapi.fetcher.OpenApiSchemaFetcher; +import com.oracle.mcp.openapi.fetcher.RestApiExecutionService; +import com.oracle.mcp.openapi.model.McpServerConfig; +import com.oracle.mcp.openapi.tool.OpenApiToMcpToolConverter; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpServerTransportProvider; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.support.BeanDefinitionBuilder; +import org.springframework.beans.factory.support.DefaultListableBeanFactory; +import org.springframework.boot.CommandLineRunner; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification; +import io.modelcontextprotocol.server.McpSyncServer; + +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.context.annotation.Bean; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.*; + +@SpringBootApplication +public class OpenApiMcpServer implements CommandLineRunner { + + @Autowired + OpenApiSchemaFetcher openApiSchemaFetcher; + + @Autowired + OpenApiToMcpToolConverter openApiToMcpToolConverter; + + @Autowired + ConfigurableApplicationContext context; + + @Autowired + McpServerCacheService mcpServerCacheService; + + @Autowired + RestApiExecutionService restApiExecutionService; + + private static final Logger LOGGER = LoggerFactory.getLogger(OpenApiMcpServer.class); + + + + public static void main(String[] args) { + SpringApplication.run(OpenApiMcpServer.class, args); + } + + @Override + public void run(String... args) { + if (args.length == 0) { + System.err.println("Usage: java -jar app.jar "); + return; + } + + } + + @Bean + public CommandLineRunner commandRunner() { + return args -> { + // No latch, register immediately + McpServerConfig argument = McpServerConfig.fromArgs(args); + mcpServerCacheService.putServerConfig(argument); + // Fetch and convert OpenAPI to tools + JsonNode openApiJson = openApiSchemaFetcher.fetch(argument); + List mcpTools = openApiToMcpToolConverter.convertJsonToMcpTools(openApiJson); + + // Build MCP server + McpSchema.ServerCapabilities serverCapabilities = McpSchema.ServerCapabilities.builder() + .tools(false) + .resources(false, false) + .prompts(false) + .logging() + .completions() + .build(); + + McpServerTransportProvider stdInOutTransport = + new StdioServerTransportProvider(new ObjectMapper(), System.in, System.out); + + McpSyncServer mcpSyncServer = McpServer.sync(stdInOutTransport) + .serverInfo("openapi-mcp-server", "1.0.0") + .capabilities(serverCapabilities) + .build(); + // Register tools + for (McpSchema.Tool tool : mcpTools) { + SyncToolSpecification syncTool = SyncToolSpecification.builder() + .tool(tool) + .callHandler((exchange, callRequest) -> { + String response=""; + try { + McpSchema.Tool toolToExecute = mcpServerCacheService.getTool(callRequest.name()); + String httpMethod = toolToExecute.meta().get("httpMethod").toString(); + String path = toolToExecute.meta().get("path").toString(); + + McpServerConfig config = mcpServerCacheService.getServerConfig(); + String url = config.getApiBaseUrl() + path; + Map arguments =callRequest.arguments(); + Map> pathParams = (Map>) Optional.ofNullable(toolToExecute.meta().get("pathParams")) + .orElse(Collections.emptyMap()); + Map> queryParams = (Map>) Optional.ofNullable(toolToExecute.meta().get("queryParams")) + .orElse(Collections.emptyMap()); + String formattedUrl = url; + Iterator> iterator = arguments.entrySet().iterator(); + LOGGER.debug("Path params {}", pathParams); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (pathParams.containsKey(entry.getKey())) { + LOGGER.info("Entry {}", new ObjectMapper().writeValueAsString(entry)); + + String placeholder = "{" + entry.getKey() + "}"; + String value = entry.getValue() != null ? entry.getValue().toString() : ""; + formattedUrl = formattedUrl.replace(placeholder, value); + iterator.remove(); + } + } + LOGGER.info("Formated URL {}", formattedUrl); + + OpenApiSchemaAuthType authType = config.getAuthType(); + Map headers = new java.util.HashMap<>(); + if (authType == OpenApiSchemaAuthType.BASIC) { + String encoded = Base64.getEncoder().encodeToString( + (config.getAuthUsername() + ":" + config.getAuthPassword()) + .getBytes(StandardCharsets.UTF_8) + ); + headers.put("Authorization", "Basic " + encoded); + } + String body = new ObjectMapper().writeValueAsString(arguments); + response = restApiExecutionService.executeRequest(formattedUrl,httpMethod,body,headers); + LOGGER.info("Server exchange {}", new ObjectMapper().writeValueAsString(toolToExecute)); + LOGGER.info("Server callRequest {}", new ObjectMapper().writeValueAsString(callRequest)); + + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } catch (IOException e) { + throw new RuntimeException(e); + } + return McpSchema.CallToolResult.builder() + .structuredContent(response) + .build(); + }) + .build(); + mcpSyncServer.addTool(syncTool); + } + DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) context.getBeanFactory(); + + BeanDefinitionBuilder beanDefinitionBuilder = BeanDefinitionBuilder + .genericBeanDefinition(McpSyncServer.class); + + beanFactory.registerBeanDefinition("mcpSyncServer", beanDefinitionBuilder.getBeanDefinition()); + + }; + } +} diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/cache/McpServerCacheService.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/cache/McpServerCacheService.java new file mode 100644 index 00000000..77a5e148 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/cache/McpServerCacheService.java @@ -0,0 +1,32 @@ +package com.oracle.mcp.openapi.cache; + +import com.oracle.mcp.openapi.model.McpServerConfig; +import io.modelcontextprotocol.spec.McpSchema; + +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +public class McpServerCacheService { + + + private final ConcurrentMap toolListCache = new ConcurrentHashMap<>(); + private McpServerConfig serverConfig; + + // ===== MCP TOOL LIST ===== + public void putTool(String key, McpSchema.Tool tool) { + toolListCache.put(key, tool); + } + + public McpSchema.Tool getTool(String key) { + return toolListCache.get(key); + } + + // ===== MCP SERVER CONFIG (single instance) ===== + public void putServerConfig(McpServerConfig config) { + serverConfig = config; + } + + public McpServerConfig getServerConfig() { + return serverConfig; + } +} diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java new file mode 100644 index 00000000..2c727006 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java @@ -0,0 +1,61 @@ +package com.oracle.mcp.openapi.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.oracle.mcp.openapi.cache.McpServerCacheService; +import com.oracle.mcp.openapi.fetcher.OpenApiSchemaFetcher; +import com.oracle.mcp.openapi.fetcher.RestApiExecutionService; +import com.oracle.mcp.openapi.tool.OpenApiToMcpToolConverter; + +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + + +import java.net.http.HttpClient; +import java.time.Duration; + +@Configuration +public class OpenApiMcpServerConfiguration { + + @Bean + public HttpClient httpClient() { + return HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + @Bean("jsonMapper") + public ObjectMapper jsonMapper() { + return new ObjectMapper(); + } + + @Bean("yamlMapper") + public ObjectMapper yamlMapper() { + return new ObjectMapper(new YAMLFactory()); + } + + @Bean + public McpServerCacheService mcpServerCacheService(){ + return new McpServerCacheService(); + } + + @Bean + public RestApiExecutionService restApiExecutionService(){ + return new RestApiExecutionService(); + } + + @Bean + public OpenApiToMcpToolConverter openApiToMcpToolConverter(McpServerCacheService mcpServerCacheService) { + return new OpenApiToMcpToolConverter(mcpServerCacheService); + } + + + @Bean + public OpenApiSchemaFetcher openApiDefinitionFetcher(HttpClient httpClient, @Qualifier("jsonMapper") ObjectMapper jsonMapper, @Qualifier("yamlMapper") ObjectMapper yamlMapper){ + return new OpenApiSchemaFetcher(httpClient,jsonMapper,yamlMapper); + } + + + +} diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaAuthType.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaAuthType.java new file mode 100644 index 00000000..9856d961 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaAuthType.java @@ -0,0 +1,15 @@ +package com.oracle.mcp.openapi.enums; + +import com.oracle.mcp.openapi.model.McpServerConfig; + +public enum OpenApiSchemaAuthType { + BASIC,UNKNOWN; + + public static OpenApiSchemaAuthType getType(McpServerConfig request){ + String authType = request.getRawAuthType(); + if(authType.equals(BASIC.name())){ + return BASIC; + } + return UNKNOWN; + } +} diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaSourceType.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaSourceType.java new file mode 100644 index 00000000..342b834c --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaSourceType.java @@ -0,0 +1,17 @@ +package com.oracle.mcp.openapi.enums; + +import com.oracle.mcp.openapi.model.McpServerConfig; + +public enum OpenApiSchemaSourceType { + URL,FILE; + + public static OpenApiSchemaSourceType getType(McpServerConfig request){ + + if(request.getSpecUrl()!=null){ + return URL; + }else{ + return FILE; + } + + } +} diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaType.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaType.java new file mode 100644 index 00000000..a6a4f52c --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaType.java @@ -0,0 +1,46 @@ +package com.oracle.mcp.openapi.enums; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; + +public enum OpenApiSchemaType { + JSON, + YAML, + UNKNOWN; + + // 1. Create reusable, thread-safe mapper instances. This is much more efficient. + private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); + private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); + + /** + * Determines the schema file type from a string by attempting to parse it. + * + * @param dataString The string content of the schema file. + * @return The determined OpenApiSchemaFileType (JSON, YAML, or UNKNOWN). + */ + public static OpenApiSchemaType getType(String dataString) { + if (dataString == null || dataString.trim().isEmpty()) { + return UNKNOWN; + } + + // First, try to parse as JSON + try { + JSON_MAPPER.readTree(dataString); + return JSON; + } catch (JsonProcessingException e) { + // It's not JSON, so we proceed to check for YAML. + } + + // If JSON parsing failed, try YAML + try { + YAML_MAPPER.readTree(dataString); + return YAML; + } catch (JsonProcessingException e) { + // It's not YAML either. + } + + // If both attempts fail, return UNKNOWN + return UNKNOWN; + } +} \ No newline at end of file diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/AuthConfig.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/AuthConfig.java new file mode 100644 index 00000000..d704a0e3 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/AuthConfig.java @@ -0,0 +1,13 @@ +package com.oracle.mcp.openapi.fetcher; + +public record AuthConfig( + AuthType type, + String username, // for BASIC + String password, // for BASIC + String token, // for BEARER_TOKEN or API_KEY + String apiKeyName // if API_KEY in query/header +) { + public static AuthConfig none() { + return new AuthConfig(AuthType.NONE, null, null, null, null); + } +} \ No newline at end of file diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/AuthType.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/AuthType.java new file mode 100644 index 00000000..151f2939 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/AuthType.java @@ -0,0 +1,8 @@ +package com.oracle.mcp.openapi.fetcher; + +public enum AuthType { + NONE, // No authentication + BASIC, // Basic auth with username/password + BEARER_TOKEN, // OAuth2/JWT bearer token + API_KEY // API key in header or query param +} \ No newline at end of file diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java new file mode 100644 index 00000000..f60c32a2 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java @@ -0,0 +1,76 @@ +package com.oracle.mcp.openapi.fetcher; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.oracle.mcp.openapi.enums.OpenApiSchemaAuthType; +import com.oracle.mcp.openapi.model.McpServerConfig; +import com.oracle.mcp.openapi.enums.OpenApiSchemaSourceType; +import com.oracle.mcp.openapi.enums.OpenApiSchemaType; + +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.http.HttpClient; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public class OpenApiSchemaFetcher { + + private final HttpClient httpClient ; + private final ObjectMapper jsonMapper ; + private final ObjectMapper yamlMapper; + + public OpenApiSchemaFetcher(HttpClient httpClient, ObjectMapper jsonMapper, ObjectMapper yamlMapper){ + this.httpClient = httpClient; + this.jsonMapper = jsonMapper; + this.yamlMapper = yamlMapper; + } + + public JsonNode fetch(McpServerConfig mcpServerConfig) throws Exception { + OpenApiSchemaSourceType type = OpenApiSchemaSourceType.getType(mcpServerConfig); + String content = null; + if(type == OpenApiSchemaSourceType.URL){ + content = downloadContent(mcpServerConfig); + }else{ + //TODO File + } + + return parseContent(content); + } + + private String downloadContent(McpServerConfig mcpServerConfig) throws Exception { + URL url = new URL(mcpServerConfig.getSpecUrl()); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(10_000); + conn.setReadTimeout(10_000); + conn.setRequestMethod("GET"); + + applyAuth(conn, mcpServerConfig); + + try (InputStream in = conn.getInputStream()) { + byte[] bytes = in.readAllBytes(); + return new String(bytes, StandardCharsets.UTF_8); + } + } + + private void applyAuth(HttpURLConnection conn, McpServerConfig mcpServerConfig) { + OpenApiSchemaAuthType authType = mcpServerConfig.getAuthType(); + if (authType == OpenApiSchemaAuthType.BASIC) { + String encoded = Base64.getEncoder().encodeToString( + (mcpServerConfig.getAuthUsername() + ":" + mcpServerConfig.getAuthPassword()) + .getBytes(StandardCharsets.UTF_8) + ); + conn.setRequestProperty("Authorization", "Basic " + encoded); + } + } + + private JsonNode parseContent(String content) throws Exception { + OpenApiSchemaType type = OpenApiSchemaType.getType(content); + if (type == OpenApiSchemaType.YAML) { + return yamlMapper.readTree(content); + } else { + return jsonMapper.readTree(content); + } + } + +} diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/RestApiExecutionService.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/RestApiExecutionService.java new file mode 100644 index 00000000..65c48282 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/RestApiExecutionService.java @@ -0,0 +1,98 @@ +package com.oracle.mcp.openapi.fetcher; + +import java.io.BufferedReader; +import java.io.DataOutputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.net.HttpURLConnection; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.util.Map; + +public class RestApiExecutionService { + + // HTTP Method constants (Java doesn't provide these by default) + public static final String GET = "GET"; + public static final String POST = "POST"; + public static final String PUT = "PUT"; + public static final String DELETE = "DELETE"; + public static final String PATCH = "PATCH"; + + /** + * Executes an HTTP request. + * + * @param targetUrl the URL to call + * @param method HTTP method (GET, POST, etc.) + * @param body Request body (only for POST, PUT, PATCH) + * @param headers Optional headers + * @return the response as String + * @throws IOException if an I/O error occurs + */ + public String executeRequest(String targetUrl, String method, String body, Map headers) throws IOException { + HttpURLConnection connection = null; + try { + // Create connection + URL url = java.net.URI.create(targetUrl).toURL(); + connection = (HttpURLConnection) url.openConnection(); + connection.setRequestMethod(method); + + // Add headers + if (headers != null) { + for (Map.Entry entry : headers.entrySet()) { + connection.setRequestProperty(entry.getKey(), entry.getValue()); + } + } + + // If method supports a body, set Content-Type to application/json + if (body != null && !body.isEmpty() && + (POST.equalsIgnoreCase(method) || PUT.equalsIgnoreCase(method) || PATCH.equalsIgnoreCase(method))) { + connection.setRequestProperty("Content-Type", "application/json"); + connection.setDoOutput(true); + try (DataOutputStream wr = new DataOutputStream(connection.getOutputStream())) { + wr.write(body.getBytes(StandardCharsets.UTF_8)); + } + } + + // Read response (success or error) + int responseCode = connection.getResponseCode(); + try (BufferedReader in = new BufferedReader( + new InputStreamReader( + responseCode >= 200 && responseCode < 300 + ? connection.getInputStream() + : connection.getErrorStream() + ))) { + StringBuilder response = new StringBuilder(); + String line; + while ((line = in.readLine()) != null) { + response.append(line); + } + return response.toString(); + } + } finally { + if (connection != null) { + connection.disconnect(); + } + } + } + + // --- Convenience methods --- + public String get(String url, Map headers) throws IOException { + return executeRequest(url, GET, null, headers); + } + + public String post(String url, String body, Map headers) throws IOException { + return executeRequest(url, POST, body, headers); + } + + public String put(String url, String body, Map headers) throws IOException { + return executeRequest(url, PUT, body, headers); + } + + public String delete(String url, Map headers) throws IOException { + return executeRequest(url, DELETE, null, headers); + } + + public String patch(String url, String body, Map headers) throws IOException { + return executeRequest(url, PATCH, body, headers); + } +} diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/McpServerConfig.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/McpServerConfig.java new file mode 100644 index 00000000..d603e729 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/McpServerConfig.java @@ -0,0 +1,126 @@ +package com.oracle.mcp.openapi.model; + +import com.oracle.mcp.openapi.enums.OpenApiSchemaAuthType; + +import java.util.HashMap; +import java.util.Map; + +/** + * Represents parsed command-line arguments for the MCP OpenAPI server. + * Immutable once constructed. + * Secrets (token, password, api-key) are stored in char arrays + * and should be cleared by the consumer after use. + * + * Environment variables override CLI arguments if both are present. + */ +public final class McpServerConfig { + + // Specification source + private final String apiName; + private final String apiBaseUrl; + private final String specUrl; + private final String specPath; + + // Authentication details + private final String authType; // raw string + private final char[] authToken; + private final String authUsername; + private final char[] authPassword; + private final char[] authApiKey; + private final String authApiKeyName; + private final String authApiKeyIn; + + private McpServerConfig(String apiName, String apiBaseUrl, String specUrl, String specPath, + String authType, char[] authToken, String authUsername, + char[] authPassword, char[] authApiKey, String authApiKeyName, + String authApiKeyIn) { + this.apiName = apiName; + this.apiBaseUrl = apiBaseUrl; + this.specUrl = specUrl; + this.specPath = specPath; + this.authType = authType; + this.authToken = authToken != null ? authToken.clone() : null; + this.authUsername = authUsername; + this.authPassword = authPassword != null ? authPassword.clone() : null; + this.authApiKey = authApiKey != null ? authApiKey.clone() : null; + this.authApiKeyName = authApiKeyName; + this.authApiKeyIn = authApiKeyIn; + } + + // ----------------- GETTERS ----------------- + + public String getApiName() { return apiName; } + public String getApiBaseUrl() { return apiBaseUrl; } + public String getSpecUrl() { return specUrl; } + public String getSpecPath() { return specPath; } + public String getRawAuthType() { return authType; } + public OpenApiSchemaAuthType getAuthType() { return OpenApiSchemaAuthType.getType(this); } + public String getAuthUsername() { return authUsername; } + public char[] getAuthToken() { return authToken != null ? authToken.clone() : null; } + public String getAuthPassword() { return authPassword != null ? new String(authPassword) : null; } + public char[] getAuthApiKey() { return authApiKey != null ? authApiKey.clone() : null; } + public String getAuthApiKeyName() { return authApiKeyName; } + public String getAuthApiKeyIn() { return authApiKeyIn; } + + // ----------------- FACTORY METHOD ----------------- + + public static McpServerConfig fromArgs(String[] args) { + Map argMap = toMap(args); + + // ----------------- API info ----------------- + String apiName = getStringValue(argMap.get("--api-name"), "API_NAME"); + String apiBaseUrl = getStringValue(argMap.get("--api-base-url"), "API_BASE_URL"); + String specUrl = getStringValue(argMap.get("--spec-url"), "API_SPEC_URL"); + String specPath = getStringValue(argMap.get("--spec-path"), "API_SPEC_PATH"); + + if (specUrl == null && specPath == null) { + throw new IllegalArgumentException("Either --spec-url or --spec-path is required."); + } + if (specUrl != null && specPath != null) { + throw new IllegalArgumentException("Provide either --spec-url or --spec-path, but not both."); + } + + // ----------------- Authentication ----------------- + String authType = getStringValue(argMap.get("--auth-type"), "AUTH_TYPE"); + char[] authToken = getCharValue(argMap.get("--auth-token"), "AUTH_TOKEN"); + String authUsername = getStringValue(argMap.get("--auth-username"), "AUTH_USERNAME"); + char[] authPassword = getCharValue(argMap.get("--auth-password"), "AUTH_PASSWORD"); + char[] authApiKey = getCharValue(argMap.get("--auth-api-key"), "AUTH_API_KEY"); + String authApiKeyName = getStringValue(argMap.get("--auth-api-key-name"), "AUTH_API_KEY_NAME"); + String authApiKeyIn = getStringValue(argMap.get("--auth-api-key-in"), "AUTH_API_KEY_IN"); + + return new McpServerConfig(apiName, apiBaseUrl, specUrl, specPath, + authType, authToken, authUsername, authPassword, authApiKey, + authApiKeyName, authApiKeyIn); + } + + // ----------------- HELPERS ----------------- + + private static char[] getCharValue(String cliValue, String envVarName) { + String envValue = System.getenv(envVarName); + String secret = envValue != null ? envValue : cliValue; + return secret != null ? secret.toCharArray() : null; + } + + private static String getStringValue(String cliValue, String envVarName) { + String envValue = System.getenv(envVarName); + return envValue != null ? envValue : cliValue; + } + + private static Map toMap(String[] args) { + Map map = new HashMap<>(); + for (int i = 0; i < args.length; i++) { + String key = args[i]; + if (key.startsWith("--")) { + if (i + 1 < args.length && !args[i + 1].startsWith("--")) { + map.put(key, args[++i]); + } else { + map.put(key, null); // flag with no value + } + } else { + throw new IllegalArgumentException("Unexpected argument: " + key); + } + } + return map; + } +} diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiToMcpToolConverter.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiToMcpToolConverter.java new file mode 100644 index 00000000..6c78b505 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiToMcpToolConverter.java @@ -0,0 +1,412 @@ +package com.oracle.mcp.openapi.tool; + +import com.fasterxml.jackson.databind.JsonNode; +import com.oracle.mcp.openapi.cache.McpServerCacheService; +import io.modelcontextprotocol.spec.McpSchema; +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.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.parser.OpenAPIV3Parser; +import io.swagger.v3.parser.core.models.ParseOptions; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; + +public class OpenApiToMcpToolConverter { + + private final Set usedNames = new HashSet<>(); + private final McpServerCacheService mcpServerCacheService; + private static final Logger LOGGER = LoggerFactory.getLogger(OpenApiToMcpToolConverter.class); + + public OpenApiToMcpToolConverter(McpServerCacheService mcpServerCacheService) { + this.mcpServerCacheService = mcpServerCacheService; + } + + public List convertJsonToMcpTools(JsonNode openApiJson) { + LOGGER.debug("Parsing OpenAPI JsonNode to OpenAPI object..."); + OpenAPI openAPI = parseOpenApi(openApiJson); + + if (openAPI.getPaths() == null || openAPI.getPaths().isEmpty()) { + throw new IllegalArgumentException("'paths' object not found in the specification."); + } + + List mcpTools = processPaths(openAPI); + LOGGER.debug("Conversion complete. Total tools created: {}", mcpTools.size()); + updateToolsToCache(mcpTools); + return mcpTools; + } + + private List processPaths(OpenAPI openAPI) { + List mcpTools = new ArrayList<>(); + + for (Map.Entry pathEntry : openAPI.getPaths().entrySet()) { + String path = pathEntry.getKey(); + PathItem pathItem = pathEntry.getValue(); + if (pathItem == null) continue; + + processOperationsForPath(path, pathItem, mcpTools); + } + return mcpTools; + } + + private void processOperationsForPath(String path, PathItem pathItem, List mcpTools) { + for (Map.Entry methodEntry : pathItem.readOperationsMap().entrySet()) { + PathItem.HttpMethod method = methodEntry.getKey(); + Operation operation = methodEntry.getValue(); + if (operation == null) continue; + + McpSchema.Tool tool = buildToolFromOperation(path, method, operation); + if (tool != null) { + mcpTools.add(tool); + } + } + } + + private McpSchema.Tool buildToolFromOperation(String path, PathItem.HttpMethod method, Operation operation) { + String rawOperationId = (operation.getOperationId() != null && !operation.getOperationId().isEmpty()) + ? operation.getOperationId() + : toCamelCase(method.name() + " " + path); + + String toolName = makeUniqueName(rawOperationId); + LOGGER.debug("--- Parsing Operation: {} {} (ID: {}) ---", method.name().toUpperCase(), path, toolName); + + String toolTitle = (operation.getSummary() != null && !operation.getSummary().isEmpty()) + ? operation.getSummary() + : toolName; + String toolDescription = getDescription(toolName, method.name(), path, operation); + + Map properties = new LinkedHashMap<>(); + List requiredParams = new ArrayList<>(); + + Map> pathParams = new HashMap<>(); + Map> queryParams = new HashMap<>(); + extractPathAndQueryParams(operation, pathParams, queryParams, properties, requiredParams); + + extractRequestBody(operation, properties, requiredParams); + + McpSchema.JsonSchema inputSchema = new McpSchema.JsonSchema( + "object", + properties.isEmpty() ? null : properties, + requiredParams.isEmpty() ? null : requiredParams, + false, null, null + ); + Map outputSchema = extractOutputSchema(operation); + outputSchema.put("additionalProperties", true); + + Map meta = buildMeta(method, path, operation, pathParams, queryParams); + + return McpSchema.Tool.builder() + .title(toolTitle) + .name(toolName) + .description(toolDescription) + .inputSchema(inputSchema) + .outputSchema(outputSchema) + .meta(meta) + .build(); + } + + + private Map buildMeta(PathItem.HttpMethod method, String path, + Operation operation, Map> pathParams, + Map> queryParams) { + Map meta = new LinkedHashMap<>(); + meta.put("httpMethod", method.name()); + meta.put("path", path); + if (operation.getTags() != null) { + meta.put("tags", operation.getTags()); + } + if (operation.getSecurity() != null) { + meta.put("security", operation.getSecurity()); + } + if (!pathParams.isEmpty()) { + meta.put("pathParams", pathParams); + } + if (!queryParams.isEmpty()) { + meta.put("queryParams", queryParams); + } + return meta; + } + + // Modified helper method to populate all relevant collections + private void extractPathAndQueryParams(Operation operation, + Map> pathParams, + Map> queryParams, + Map properties, + List requiredParams) { + if (operation.getParameters() != null) { + for (Parameter param : operation.getParameters()) { + if (param.getName() == null || param.getSchema() == null) continue; + + // This part is for your meta block (original behavior) + Map paramMeta = parameterMetaMap(param); + if ("path".equalsIgnoreCase(param.getIn())) { + pathParams.put(param.getName(), paramMeta); + } else if ("query".equalsIgnoreCase(param.getIn())) { + queryParams.put(param.getName(), paramMeta); + } + + // This part is for your inputSchema + if ("path".equalsIgnoreCase(param.getIn()) || "query".equalsIgnoreCase(param.getIn())) { + + // --- MODIFICATION START --- + + // 1. Get the base schema for the parameter + Map paramSchema = extractInputSchema(param.getSchema()); + + // 2. Manually add the description from the Parameter object itself + if (param.getDescription() != null && !param.getDescription().isEmpty()) { + paramSchema.put("description", param.getDescription()); + } + + // 3. Add the complete schema (with description) to the properties map + properties.put(param.getName(), paramSchema); + + // --- MODIFICATION END --- + + + // If required, add it to the list of required parameters + if (Boolean.TRUE.equals(param.getRequired())) { + requiredParams.add(param.getName()); + } + } + } + } + } + + private void updateToolsToCache(List tools) { + for (McpSchema.Tool tool : tools) { + mcpServerCacheService.putTool(tool.name(), tool); + } + } + + public String getDescription(String toolName, String httpMethod, String path, Operation operation) { + StringBuilder doc = new StringBuilder(); + + if (operation.getSummary() != null && !operation.getSummary().isEmpty()) { + doc.append(operation.getSummary()).append("\n"); + } else if (operation.getDescription() != null && !operation.getDescription().isEmpty()) { + doc.append(operation.getDescription()).append("\n"); + } +// +// doc.append("HTTP Method : ").append(httpMethod.toUpperCase()).append("\n"); +// doc.append("End URL : ").append(" `").append(path).append("`\n"); +// +// if (operation.getParameters() != null && !operation.getParameters().isEmpty()) { +// appendParameterList(doc, "Path parameters: ", operation.getParameters(), "path"); +// appendParameterList(doc, "Query parameters: ", operation.getParameters(), "query"); +// } +// +// if (operation.getRequestBody() != null) { +// doc.append("Request body: ") +// .append(Boolean.TRUE.equals(operation.getRequestBody().getRequired()) ? "Required" : "Optional") +// .append("\n"); +// } +// +// appendExampleUsage(doc, toolName); + return doc.toString(); + } + + private void appendParameterList(StringBuilder doc, String label, List parameters, String type) { + doc.append(label); + for (Parameter p : parameters) { + if (type.equals(p.getIn())) { + doc.append(p.getName()) + .append(Boolean.TRUE.equals(p.getRequired()) ? "*" : "") + .append(","); + } + } + doc.append("\n"); + } + + private void appendExampleUsage(StringBuilder doc, String toolName) { + doc.append("Example usage: "); + doc.append("```json\n"); + doc.append("{\n"); + doc.append(" \"tool_name\": \"").append(toolName).append("\",\n"); + doc.append(" \"arguments\": {}\n"); + doc.append("}\n"); + doc.append("```\n"); + } + + private OpenAPI parseOpenApi(JsonNode jsonNode) { + String jsonString = jsonNode.toString(); + ParseOptions options = new ParseOptions(); + options.setResolve(true); + options.setResolveFully(true); + return new OpenAPIV3Parser().readContents(jsonString, null, options).getOpenAPI(); + } + + public McpSchema.JsonSchema buildInputSchemaFromOperation(Operation operation) { + Map properties = new LinkedHashMap<>(); + List requiredParams = new ArrayList<>(); + + extractRequestBody(operation, properties, requiredParams); + + // This now assumes McpSchema.JsonSchema is defined elsewhere in your project + return new McpSchema.JsonSchema( + "object", + properties.isEmpty() ? null : properties, + requiredParams.isEmpty() ? null : requiredParams, + false, null, null + ); + } + + private void extractRequestBody(Operation operation, Map properties, List requiredParams) { + // Extract from request body + if (operation.getRequestBody() != null && operation.getRequestBody().getContent() != null) { + // Assuming a single JSON media type for simplicity + MediaType media = operation.getRequestBody().getContent().get("application/json"); + if (media != null && media.getSchema() != null) { + Schema bodySchema = media.getSchema(); + + if ("object".equals(bodySchema.getType()) && bodySchema.getProperties() != null) { + bodySchema.getProperties().forEach((name, schema) -> { + properties.put(name.toString(), extractInputSchema((Schema) schema)); + }); + if (bodySchema.getRequired() != null) { + requiredParams.addAll(bodySchema.getRequired()); + } + } + } + } + } + + private Map extractInputSchema(Schema openApiSchema) { + if (openApiSchema == null) { + return new LinkedHashMap<>(); + } + + Map jsonSchema = new LinkedHashMap<>(); + + // Copy basic properties + if (openApiSchema.getType() != null) jsonSchema.put("type", openApiSchema.getType()); + if (openApiSchema.getDescription() != null){ + jsonSchema.put("description", openApiSchema.getDescription()); + } + if (openApiSchema.getFormat() != null) jsonSchema.put("format", openApiSchema.getFormat()); + if (openApiSchema.getEnum() != null) jsonSchema.put("enum", openApiSchema.getEnum()); + + // --- Recursive Handling --- + + // If it's an object, process its properties recursively + if ("object".equals(openApiSchema.getType())) { + if (openApiSchema.getProperties() != null) { + Map nestedProperties = new LinkedHashMap<>(); + openApiSchema.getProperties().forEach((key, value) -> { + nestedProperties.put(key.toString(), extractInputSchema((Schema) value)); + }); + jsonSchema.put("properties", nestedProperties); + } + if (openApiSchema.getRequired() != null) { + jsonSchema.put("required", openApiSchema.getRequired()); + } + } + + // If it's an array, process its 'items' schema recursively + if ("array".equals(openApiSchema.getType())) { + if (openApiSchema.getItems() != null) { + jsonSchema.put("items", extractInputSchema(openApiSchema.getItems())); + } + } + + return jsonSchema; + } + + + public Map extractOutputSchema(Operation operation) { + if (operation.getResponses() == null || operation.getResponses().isEmpty()) { + return new HashMap<>(); + } + + ApiResponse response = operation.getResponses().get("200"); + if (response == null) { + response = operation.getResponses().get("default"); + } + if (response == null || response.getContent() == null || !response.getContent().containsKey("application/json")) { + return new HashMap<>(); + } + + Schema schema = response.getContent().get("application/json").getSchema(); + if (schema == null) { + return new HashMap<>(); + } + return extractOutputSchema(schema); + } + + public Map extractOutputSchema(Schema schema) { + if (schema == null) { + return Collections.emptyMap(); + } + + Map jsonSchema = new LinkedHashMap<>(); + + // Copy basic properties + if (schema.getType() != null) { + jsonSchema.put("type", new String[]{schema.getType(), "null"}); + } + if (schema.getDescription() != null) { + jsonSchema.put("description", schema.getDescription()); + } + + // If it's an object, recursively process its properties + if ("object".equals(schema.getType()) && schema.getProperties() != null) { + Map properties = new LinkedHashMap<>(); + for (Map.Entry entry : schema.getProperties().entrySet()) { + properties.put(entry.getKey(), extractOutputSchema(entry.getValue())); + } + jsonSchema.put("properties", properties); + } + + // Add other properties you may need, like "required", "items" for arrays, etc. + if (schema.getRequired() != null) { + jsonSchema.put("required", schema.getRequired()); + } + + + + return jsonSchema; + } + + private Map parameterMetaMap(Parameter p) { + Map paramMeta = new LinkedHashMap<>(); + paramMeta.put("name", p.getName()); + paramMeta.put("required", Boolean.TRUE.equals(p.getRequired())); + if (p.getDescription() != null) { + paramMeta.put("description", p.getDescription()); + } + if (p.getSchema() != null && p.getSchema().getType() != null) { + paramMeta.put("type", p.getSchema().getType()); + } + return paramMeta; + } + + private String makeUniqueName(String base) { + String name = base; + int counter = 1; + while (usedNames.contains(name)) { + name = base + "_" + counter++; + } + usedNames.add(name); + return name; + } + + private static String toCamelCase(String input) { + String[] parts = input.split("[^a-zA-Z0-9]+"); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < parts.length; i++) { + if (parts[i].isEmpty()) continue; + String word = parts[i].toLowerCase(); + if (i == 0) { + sb.append(word); + } else { + sb.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1)); + } + } + return sb.toString(); + } +} diff --git a/src/openapi-mcp-server/src/main/resources/application.properties b/src/openapi-mcp-server/src/main/resources/application.properties new file mode 100644 index 00000000..33406d21 --- /dev/null +++ b/src/openapi-mcp-server/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.main.banner-mode=off diff --git a/src/openapi-mcp-server/src/main/resources/logback.xml b/src/openapi-mcp-server/src/main/resources/logback.xml new file mode 100644 index 00000000..a6bf0d3b --- /dev/null +++ b/src/openapi-mcp-server/src/main/resources/logback.xml @@ -0,0 +1,35 @@ + + + + + + System.err + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + ${LOG_PATH}/app.log + + ${LOG_PATH}/app-%d{yyyy-MM-dd}.%i.log + 10MB + 30 + 1GB + + + %d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + From c714ba623a929cb88f50bf7f7315a04a1f97c573 Mon Sep 17 00:00:00 2001 From: Joby Wilson Mathews Date: Sat, 23 Aug 2025 09:54:28 +0530 Subject: [PATCH 02/12] Changes: - added more Network configs env variable , like proxy..etc - Changed HttpURLConnection to HttpClient - Added Custom exception class --- .../oracle/mcp/openapi/OpenApiMcpServer.java | 4 +- .../config/OpenApiMcpServerConfiguration.java | 26 ++--- .../openapi/enums/OpenApiSchemaAuthType.java | 14 ++- .../enums/OpenApiSchemaSourceType.java | 20 +++- .../openapi/exception/OpenApiException.java | 13 +++ .../mcp/openapi/fetcher/AuthConfig.java | 13 --- .../openapi/fetcher/OpenApiSchemaFetcher.java | 82 +++++++++---- .../fetcher/RestApiExecutionService.java | 98 ---------------- .../mcp/openapi/model/McpServerConfig.java | 108 +++++++++++++++--- .../mcp/openapi/rest/HttpClientFactory.java | 63 ++++++++++ .../openapi/rest/RestApiExecutionService.java | 91 +++++++++++++++ 11 files changed, 350 insertions(+), 182 deletions(-) create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/OpenApiException.java delete mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/AuthConfig.java delete mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/RestApiExecutionService.java create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/HttpClientFactory.java create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiExecutionService.java diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java index 31094214..5c1778cc 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java @@ -6,7 +6,7 @@ import com.oracle.mcp.openapi.cache.McpServerCacheService; import com.oracle.mcp.openapi.enums.OpenApiSchemaAuthType; import com.oracle.mcp.openapi.fetcher.OpenApiSchemaFetcher; -import com.oracle.mcp.openapi.fetcher.RestApiExecutionService; +import com.oracle.mcp.openapi.rest.RestApiExecutionService; import com.oracle.mcp.openapi.model.McpServerConfig; import com.oracle.mcp.openapi.tool.OpenApiToMcpToolConverter; import io.modelcontextprotocol.server.McpServer; @@ -140,7 +140,7 @@ public CommandLineRunner commandRunner() { LOGGER.info("Server exchange {}", new ObjectMapper().writeValueAsString(toolToExecute)); LOGGER.info("Server callRequest {}", new ObjectMapper().writeValueAsString(callRequest)); - } catch (JsonProcessingException e) { + } catch (JsonProcessingException | InterruptedException e) { throw new RuntimeException(e); } catch (IOException e) { throw new RuntimeException(e); diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java index 2c727006..ad3c380e 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java @@ -4,26 +4,17 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.oracle.mcp.openapi.cache.McpServerCacheService; import com.oracle.mcp.openapi.fetcher.OpenApiSchemaFetcher; -import com.oracle.mcp.openapi.fetcher.RestApiExecutionService; +import com.oracle.mcp.openapi.rest.HttpClientFactory; +import com.oracle.mcp.openapi.rest.RestApiExecutionService; import com.oracle.mcp.openapi.tool.OpenApiToMcpToolConverter; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; - -import java.net.http.HttpClient; -import java.time.Duration; - @Configuration public class OpenApiMcpServerConfiguration { - @Bean - public HttpClient httpClient() { - return HttpClient.newBuilder() - .connectTimeout(Duration.ofSeconds(10)) - .build(); - } @Bean("jsonMapper") public ObjectMapper jsonMapper() { @@ -41,8 +32,8 @@ public McpServerCacheService mcpServerCacheService(){ } @Bean - public RestApiExecutionService restApiExecutionService(){ - return new RestApiExecutionService(); + public RestApiExecutionService restApiExecutionService(McpServerCacheService mcpServerCacheService){ + return new RestApiExecutionService(mcpServerCacheService); } @Bean @@ -52,8 +43,13 @@ public OpenApiToMcpToolConverter openApiToMcpToolConverter(McpServerCacheService @Bean - public OpenApiSchemaFetcher openApiDefinitionFetcher(HttpClient httpClient, @Qualifier("jsonMapper") ObjectMapper jsonMapper, @Qualifier("yamlMapper") ObjectMapper yamlMapper){ - return new OpenApiSchemaFetcher(httpClient,jsonMapper,yamlMapper); + public OpenApiSchemaFetcher openApiDefinitionFetcher(RestApiExecutionService restApiExecutionService,@Qualifier("jsonMapper") ObjectMapper jsonMapper, @Qualifier("yamlMapper") ObjectMapper yamlMapper){ + return new OpenApiSchemaFetcher(restApiExecutionService,jsonMapper,yamlMapper); + } + + @Bean + public HttpClientFactory httpClientFactory(){ + return new HttpClientFactory(); } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaAuthType.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaAuthType.java index 9856d961..e920f647 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaAuthType.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaAuthType.java @@ -3,13 +3,17 @@ import com.oracle.mcp.openapi.model.McpServerConfig; public enum OpenApiSchemaAuthType { - BASIC,UNKNOWN; + BASIC, BEARER, API_KEY, CUSTOM, NONE; - public static OpenApiSchemaAuthType getType(McpServerConfig request){ + public static OpenApiSchemaAuthType getType(McpServerConfig request) { String authType = request.getRawAuthType(); - if(authType.equals(BASIC.name())){ - return BASIC; + if (authType == null || authType.isEmpty()) { + return NONE; + } + try { + return OpenApiSchemaAuthType.valueOf(authType.toUpperCase()); + } catch (IllegalArgumentException ex) { + return NONE; } - return UNKNOWN; } } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaSourceType.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaSourceType.java index 342b834c..6490ecf6 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaSourceType.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaSourceType.java @@ -3,15 +3,23 @@ import com.oracle.mcp.openapi.model.McpServerConfig; public enum OpenApiSchemaSourceType { - URL,FILE; + URL, FILE; - public static OpenApiSchemaSourceType getType(McpServerConfig request){ - - if(request.getSpecUrl()!=null){ + public static OpenApiSchemaSourceType getType(McpServerConfig request) { + // Backward compatibility: prefer specUrl if set + String specUrl = request.getSpecUrl(); + if (specUrl != null && !specUrl.trim().isEmpty()) { return URL; - }else{ - return FILE; } + String specLocation = request.getApiSpec(); + if (specLocation == null || specLocation.trim().isEmpty()) { + throw new IllegalArgumentException("No specUrl or specLocation defined in McpServerConfig."); + } + + if (specLocation.startsWith("http://") || specLocation.startsWith("https://")) { + return URL; + } + return FILE; } } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/OpenApiException.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/OpenApiException.java new file mode 100644 index 00000000..67a95a9f --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/OpenApiException.java @@ -0,0 +1,13 @@ +package com.oracle.mcp.openapi.exception; + +public class OpenApiException extends Exception{ + + + public OpenApiException(String message) { + super(message); + } + + public OpenApiException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/AuthConfig.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/AuthConfig.java deleted file mode 100644 index d704a0e3..00000000 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/AuthConfig.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.oracle.mcp.openapi.fetcher; - -public record AuthConfig( - AuthType type, - String username, // for BASIC - String password, // for BASIC - String token, // for BEARER_TOKEN or API_KEY - String apiKeyName // if API_KEY in query/header -) { - public static AuthConfig none() { - return new AuthConfig(AuthType.NONE, null, null, null, null); - } -} \ No newline at end of file diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java index f60c32a2..7d0a3712 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java @@ -3,25 +3,34 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.oracle.mcp.openapi.enums.OpenApiSchemaAuthType; +import com.oracle.mcp.openapi.exception.OpenApiException; import com.oracle.mcp.openapi.model.McpServerConfig; import com.oracle.mcp.openapi.enums.OpenApiSchemaSourceType; import com.oracle.mcp.openapi.enums.OpenApiSchemaType; +import com.oracle.mcp.openapi.rest.RestApiExecutionService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; -import java.io.InputStream; -import java.net.HttpURLConnection; -import java.net.URL; -import java.net.http.HttpClient; +import java.io.IOException; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.util.Base64; +import java.util.HashMap; +import java.util.Map; public class OpenApiSchemaFetcher { - private final HttpClient httpClient ; + private static final Logger LOGGER = LoggerFactory.getLogger(OpenApiSchemaFetcher.class); + + private final ObjectMapper jsonMapper ; private final ObjectMapper yamlMapper; + private final RestApiExecutionService restApiExecutionService; - public OpenApiSchemaFetcher(HttpClient httpClient, ObjectMapper jsonMapper, ObjectMapper yamlMapper){ - this.httpClient = httpClient; + public OpenApiSchemaFetcher(RestApiExecutionService restApiExecutionService, ObjectMapper jsonMapper, ObjectMapper yamlMapper){ + this.restApiExecutionService = restApiExecutionService; this.jsonMapper = jsonMapper; this.yamlMapper = yamlMapper; } @@ -32,38 +41,61 @@ public JsonNode fetch(McpServerConfig mcpServerConfig) throws Exception { if(type == OpenApiSchemaSourceType.URL){ content = downloadContent(mcpServerConfig); }else{ - //TODO File + content = loadFromFile(mcpServerConfig); } return parseContent(content); } - private String downloadContent(McpServerConfig mcpServerConfig) throws Exception { - URL url = new URL(mcpServerConfig.getSpecUrl()); - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - conn.setConnectTimeout(10_000); - conn.setReadTimeout(10_000); - conn.setRequestMethod("GET"); + private String loadFromFile(McpServerConfig mcpServerConfig) throws IOException { + Path path = Paths.get(mcpServerConfig.getApiSpec()); + return Files.readString(path, StandardCharsets.UTF_8); + } + + private String downloadContent(McpServerConfig mcpServerConfig) throws OpenApiException { + try { + String url = mcpServerConfig.getSpecUrl(); + + // You can also build headers here via applyAuth if you prefer + Map headers = applyAuth(mcpServerConfig); - applyAuth(conn, mcpServerConfig); + return restApiExecutionService.get(url, headers); - try (InputStream in = conn.getInputStream()) { - byte[] bytes = in.readAllBytes(); - return new String(bytes, StandardCharsets.UTF_8); + } catch (IOException | InterruptedException e) { + String errorMessage = "Failed to download OpenAPI schema."; + LOGGER.error(errorMessage, e); + throw new OpenApiException(errorMessage, e); } } - private void applyAuth(HttpURLConnection conn, McpServerConfig mcpServerConfig) { + + private Map applyAuth(McpServerConfig mcpServerConfig) { + Map headers = new HashMap<>(); OpenApiSchemaAuthType authType = mcpServerConfig.getAuthType(); - if (authType == OpenApiSchemaAuthType.BASIC) { - String encoded = Base64.getEncoder().encodeToString( - (mcpServerConfig.getAuthUsername() + ":" + mcpServerConfig.getAuthPassword()) - .getBytes(StandardCharsets.UTF_8) - ); - conn.setRequestProperty("Authorization", "Basic " + encoded); + if (authType == null || authType == OpenApiSchemaAuthType.NONE) { + return headers; } + + switch (authType) { + case BASIC -> { + String encoded = Base64.getEncoder().encodeToString( + (mcpServerConfig.getAuthUsername() + ":" + mcpServerConfig.getAuthPassword()) + .getBytes(StandardCharsets.UTF_8) + ); + headers.put("Authorization", "Basic " + encoded); + } + + case BEARER -> { + headers.put("Authorization", "Bearer " + mcpServerConfig.getAuthToken()); + } + default -> { + // NONE or unrecognized + } + } + return headers; } + private JsonNode parseContent(String content) throws Exception { OpenApiSchemaType type = OpenApiSchemaType.getType(content); if (type == OpenApiSchemaType.YAML) { diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/RestApiExecutionService.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/RestApiExecutionService.java deleted file mode 100644 index 65c48282..00000000 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/RestApiExecutionService.java +++ /dev/null @@ -1,98 +0,0 @@ -package com.oracle.mcp.openapi.fetcher; - -import java.io.BufferedReader; -import java.io.DataOutputStream; -import java.io.IOException; -import java.io.InputStreamReader; -import java.net.HttpURLConnection; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.util.Map; - -public class RestApiExecutionService { - - // HTTP Method constants (Java doesn't provide these by default) - public static final String GET = "GET"; - public static final String POST = "POST"; - public static final String PUT = "PUT"; - public static final String DELETE = "DELETE"; - public static final String PATCH = "PATCH"; - - /** - * Executes an HTTP request. - * - * @param targetUrl the URL to call - * @param method HTTP method (GET, POST, etc.) - * @param body Request body (only for POST, PUT, PATCH) - * @param headers Optional headers - * @return the response as String - * @throws IOException if an I/O error occurs - */ - public String executeRequest(String targetUrl, String method, String body, Map headers) throws IOException { - HttpURLConnection connection = null; - try { - // Create connection - URL url = java.net.URI.create(targetUrl).toURL(); - connection = (HttpURLConnection) url.openConnection(); - connection.setRequestMethod(method); - - // Add headers - if (headers != null) { - for (Map.Entry entry : headers.entrySet()) { - connection.setRequestProperty(entry.getKey(), entry.getValue()); - } - } - - // If method supports a body, set Content-Type to application/json - if (body != null && !body.isEmpty() && - (POST.equalsIgnoreCase(method) || PUT.equalsIgnoreCase(method) || PATCH.equalsIgnoreCase(method))) { - connection.setRequestProperty("Content-Type", "application/json"); - connection.setDoOutput(true); - try (DataOutputStream wr = new DataOutputStream(connection.getOutputStream())) { - wr.write(body.getBytes(StandardCharsets.UTF_8)); - } - } - - // Read response (success or error) - int responseCode = connection.getResponseCode(); - try (BufferedReader in = new BufferedReader( - new InputStreamReader( - responseCode >= 200 && responseCode < 300 - ? connection.getInputStream() - : connection.getErrorStream() - ))) { - StringBuilder response = new StringBuilder(); - String line; - while ((line = in.readLine()) != null) { - response.append(line); - } - return response.toString(); - } - } finally { - if (connection != null) { - connection.disconnect(); - } - } - } - - // --- Convenience methods --- - public String get(String url, Map headers) throws IOException { - return executeRequest(url, GET, null, headers); - } - - public String post(String url, String body, Map headers) throws IOException { - return executeRequest(url, POST, body, headers); - } - - public String put(String url, String body, Map headers) throws IOException { - return executeRequest(url, PUT, body, headers); - } - - public String delete(String url, Map headers) throws IOException { - return executeRequest(url, DELETE, null, headers); - } - - public String patch(String url, String body, Map headers) throws IOException { - return executeRequest(url, PATCH, body, headers); - } -} diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/McpServerConfig.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/McpServerConfig.java index d603e729..f5d7db7c 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/McpServerConfig.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/McpServerConfig.java @@ -10,7 +10,6 @@ * Immutable once constructed. * Secrets (token, password, api-key) are stored in char arrays * and should be cleared by the consumer after use. - * * Environment variables override CLI arguments if both are present. */ public final class McpServerConfig { @@ -18,8 +17,8 @@ public final class McpServerConfig { // Specification source private final String apiName; private final String apiBaseUrl; + private final String apiSpec; private final String specUrl; - private final String specPath; // Authentication details private final String authType; // raw string @@ -30,14 +29,24 @@ public final class McpServerConfig { private final String authApiKeyName; private final String authApiKeyIn; - private McpServerConfig(String apiName, String apiBaseUrl, String specUrl, String specPath, + // Network configs + private final String connectTimeout; + private final String responseTimeout; + private final String httpVersion; + private final String redirectPolicy; + private final String proxyHost; + private final Integer proxyPort; + + private McpServerConfig(String apiName, String apiBaseUrl, String specUrl, String authType, char[] authToken, String authUsername, char[] authPassword, char[] authApiKey, String authApiKeyName, - String authApiKeyIn) { + String authApiKeyIn, String apiSpec, + String connectTimeout, String responseTimeout, + String httpVersion, String redirectPolicy, + String proxyHost, Integer proxyPort) { this.apiName = apiName; this.apiBaseUrl = apiBaseUrl; this.specUrl = specUrl; - this.specPath = specPath; this.authType = authType; this.authToken = authToken != null ? authToken.clone() : null; this.authUsername = authUsername; @@ -45,39 +54,72 @@ private McpServerConfig(String apiName, String apiBaseUrl, String specUrl, Strin this.authApiKey = authApiKey != null ? authApiKey.clone() : null; this.authApiKeyName = authApiKeyName; this.authApiKeyIn = authApiKeyIn; + this.apiSpec = apiSpec; + this.connectTimeout = connectTimeout; + this.responseTimeout = responseTimeout; + this.httpVersion = httpVersion; + this.redirectPolicy = redirectPolicy; + this.proxyHost = proxyHost; + this.proxyPort = proxyPort; } // ----------------- GETTERS ----------------- - public String getApiName() { return apiName; } public String getApiBaseUrl() { return apiBaseUrl; } public String getSpecUrl() { return specUrl; } - public String getSpecPath() { return specPath; } public String getRawAuthType() { return authType; } public OpenApiSchemaAuthType getAuthType() { return OpenApiSchemaAuthType.getType(this); } public String getAuthUsername() { return authUsername; } - public char[] getAuthToken() { return authToken != null ? authToken.clone() : null; } + public String getAuthToken() { return authToken != null ? new String(authToken) : null; } public String getAuthPassword() { return authPassword != null ? new String(authPassword) : null; } public char[] getAuthApiKey() { return authApiKey != null ? authApiKey.clone() : null; } public String getAuthApiKeyName() { return authApiKeyName; } public String getAuthApiKeyIn() { return authApiKeyIn; } + public String getApiSpec() { return apiSpec; } + + // Timeouts as long + public long getConnectTimeoutMs() { + try { + return Long.parseLong(connectTimeout); + } catch (NumberFormatException e) { + System.err.printf("Invalid connect timeout value: %s. Using default 10000ms.%n", connectTimeout); + return 10_000L; + } + } - // ----------------- FACTORY METHOD ----------------- + public long getResponseTimeoutMs() { + try { + return Long.parseLong(responseTimeout); + } catch (NumberFormatException e) { + System.err.printf("Invalid response timeout value: %s. Using default 30000ms.%n", responseTimeout); + return 30_000L; + } + } + + // Original string getters + public String getConnectTimeout() { return connectTimeout; } + public String getResponseTimeout() { return responseTimeout; } + + public String getHttpVersion() { return httpVersion; } + public String getRedirectPolicy() { return redirectPolicy; } + public String getProxyHost() { return proxyHost; } + public Integer getProxyPort() { return proxyPort; } + // ----------------- FACTORY METHOD ----------------- public static McpServerConfig fromArgs(String[] args) { Map argMap = toMap(args); // ----------------- API info ----------------- String apiName = getStringValue(argMap.get("--api-name"), "API_NAME"); String apiBaseUrl = getStringValue(argMap.get("--api-base-url"), "API_BASE_URL"); - String specUrl = getStringValue(argMap.get("--spec-url"), "API_SPEC_URL"); - String specPath = getStringValue(argMap.get("--spec-path"), "API_SPEC_PATH"); + String apiSpec = getStringValue(argMap.get("--api-spec"), "API_SPEC"); - if (specUrl == null && specPath == null) { - throw new IllegalArgumentException("Either --spec-url or --spec-path is required."); + String specUrl = getStringValue(argMap.get("--spec-url"), "API_SPEC_URL"); + if (specUrl == null && apiSpec == null) { + throw new IllegalArgumentException("Either --spec-url or --api-spec is required."); } - if (specUrl != null && specPath != null) { - throw new IllegalArgumentException("Provide either --spec-url or --spec-path, but not both."); + if (specUrl != null && apiSpec != null) { + throw new IllegalArgumentException("Provide either --spec-url or --api-spec, but not both."); } // ----------------- Authentication ----------------- @@ -89,13 +131,30 @@ public static McpServerConfig fromArgs(String[] args) { String authApiKeyName = getStringValue(argMap.get("--auth-api-key-name"), "AUTH_API_KEY_NAME"); String authApiKeyIn = getStringValue(argMap.get("--auth-api-key-in"), "AUTH_API_KEY_IN"); - return new McpServerConfig(apiName, apiBaseUrl, specUrl, specPath, + // ----------------- Network configs ----------------- + String connectTimeout = getStringValue(argMap.get("--connect-timeout"), "API_HTTP_CONNECT_TIMEOUT"); + if (connectTimeout == null) connectTimeout = "10000"; // default 10s in ms + + String responseTimeout = getStringValue(argMap.get("--response-timeout"), "API_HTTP_RESPONSE_TIMEOUT"); + if (responseTimeout == null) responseTimeout = "30000"; // default 30s in ms + + String httpVersion = getStringValue(argMap.get("--http-version"), "API_HTTP_VERSION"); + if (httpVersion == null) httpVersion = "HTTP_2"; + + String redirectPolicy = getStringValue(argMap.get("--http-redirect"), "API_HTTP_REDIRECT"); + if (redirectPolicy == null) redirectPolicy = "NORMAL"; + + String proxyHost = getStringValue(argMap.get("--proxy-host"), "API_HTTP_PROXY_HOST"); + Integer proxyPort = getIntOrNull(argMap.get("--proxy-port"), "API_HTTP_PROXY_PORT"); + + return new McpServerConfig(apiName, apiBaseUrl, specUrl, authType, authToken, authUsername, authPassword, authApiKey, - authApiKeyName, authApiKeyIn); + authApiKeyName, authApiKeyIn, apiSpec, + connectTimeout, responseTimeout, + httpVersion, redirectPolicy, proxyHost, proxyPort); } // ----------------- HELPERS ----------------- - private static char[] getCharValue(String cliValue, String envVarName) { String envValue = System.getenv(envVarName); String secret = envValue != null ? envValue : cliValue; @@ -107,6 +166,19 @@ private static String getStringValue(String cliValue, String envVarName) { return envValue != null ? envValue : cliValue; } + private static Integer getIntOrNull(String cliValue, String envVarName) { + String envValue = System.getenv(envVarName); + String value = envValue != null ? envValue : cliValue; + if (value != null) { + try { + return Integer.parseInt(value); + } catch (NumberFormatException e) { + System.err.printf("Invalid integer for %s: %s. Ignoring.%n", envVarName, value); + } + } + return null; + } + private static Map toMap(String[] args) { Map map = new HashMap<>(); for (int i = 0; i < args.length; i++) { diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/HttpClientFactory.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/HttpClientFactory.java new file mode 100644 index 00000000..aedc7517 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/HttpClientFactory.java @@ -0,0 +1,63 @@ +package com.oracle.mcp.openapi.rest; + +import com.oracle.mcp.openapi.model.McpServerConfig; + +import java.net.InetSocketAddress; +import java.net.ProxySelector; +import java.net.http.HttpClient; +import java.time.Duration; + +public class HttpClientFactory { + + + public HttpClientFactory() { + + } + + public HttpClient getClient(McpServerConfig config) { + HttpClient.Builder builder = HttpClient.newBuilder(); + + if (config.getConnectTimeout() != null) { + builder.connectTimeout(Duration.ofMillis(config.getConnectTimeoutMs())); + } + + + if (config.getHttpVersion() != null) { + switch (config.getHttpVersion().toUpperCase()) { + case "HTTP_1_1": + builder.version(HttpClient.Version.HTTP_1_1); + break; + case "HTTP_2": + builder.version(HttpClient.Version.HTTP_2); + break; + default: + throw new IllegalArgumentException("Unsupported HTTP version: " + config.getHttpVersion()); + } + } + + if (config.getRedirectPolicy() != null) { + switch (config.getRedirectPolicy().toUpperCase()) { + case "NEVER": + builder.followRedirects(HttpClient.Redirect.NEVER); + break; + case "NORMAL": + builder.followRedirects(HttpClient.Redirect.NORMAL); + break; + case "ALWAYS": + builder.followRedirects(HttpClient.Redirect.ALWAYS); + break; + default: + throw new IllegalArgumentException("Unsupported redirect policy: " + config.getRedirectPolicy()); + } + } + + if (config.getProxyHost() != null && config.getProxyPort() != null) { + builder.proxy(ProxySelector.of(new InetSocketAddress( + config.getProxyHost(), + config.getProxyPort() + ))); + } + + return builder.build(); + } +} diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiExecutionService.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiExecutionService.java new file mode 100644 index 00000000..75c5557d --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiExecutionService.java @@ -0,0 +1,91 @@ +package com.oracle.mcp.openapi.rest; + +import com.oracle.mcp.openapi.cache.McpServerCacheService; +import com.oracle.mcp.openapi.model.McpServerConfig; + +import java.io.IOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Map; +import java.util.stream.Stream; + +public class RestApiExecutionService { + + private final HttpClient httpClient; + + // HTTP Method constants + public static final String GET = "GET"; + public static final String POST = "POST"; + public static final String PUT = "PUT"; + public static final String DELETE = "DELETE"; + public static final String PATCH = "PATCH"; + + + public RestApiExecutionService(McpServerCacheService mcpServerCacheService) { + McpServerConfig mcpServerConfig = mcpServerCacheService.getServerConfig(); + this.httpClient = new HttpClientFactory().getClient(mcpServerConfig); + } + + /** + * Executes an HTTP request. + * + * @param targetUrl the URL to call + * @param method HTTP method (GET, POST, etc.) + * @param body Request body (only for POST, PUT, PATCH) + * @param headers Optional headers + * @return the response as String + * @throws IOException if an I/O error occurs + * @throws InterruptedException if the operation is interrupted + */ + public String executeRequest(String targetUrl, String method, String body, Map headers) + throws IOException, InterruptedException { + + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() + .uri(URI.create(targetUrl)) + .timeout(Duration.ofSeconds(30)); + + // Add headers + if (headers != null && !headers.isEmpty()) { + requestBuilder.headers(headers.entrySet().stream() + .flatMap(e -> Stream.of(e.getKey(), e.getValue())) + .toArray(String[]::new)); + } + + // Attach body only for methods that support it + if (body != null && !body.isEmpty() && + (POST.equalsIgnoreCase(method) || PUT.equalsIgnoreCase(method) || PATCH.equalsIgnoreCase(method))) { + requestBuilder.method(method, HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)); + } else { + requestBuilder.method(method, HttpRequest.BodyPublishers.noBody()); + } + + HttpRequest request = requestBuilder.build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + + return response.body(); + } + + public String get(String url, Map headers) throws IOException, InterruptedException { + return executeRequest(url, GET, null, headers); + } + + public String post(String url, String body, Map headers) throws IOException, InterruptedException { + return executeRequest(url, POST, body, headers); + } + + public String put(String url, String body, Map headers) throws IOException, InterruptedException { + return executeRequest(url, PUT, body, headers); + } + + public String delete(String url, Map headers) throws IOException, InterruptedException { + return executeRequest(url, DELETE, null, headers); + } + + public String patch(String url, String body, Map headers) throws IOException, InterruptedException { + return executeRequest(url, PATCH, body, headers); + } +} From b97a699f7892845c4cec2dcd69d51026b0822cd4 Mon Sep 17 00:00:00 2001 From: Joby Wilson Mathews Date: Sat, 23 Aug 2025 14:01:28 +0530 Subject: [PATCH 03/12] Added Temporary fix for api spec not downloading --- .../config/OpenApiMcpServerConfiguration.java | 14 +- .../openapi/fetcher/OpenApiSchemaFetcher.java | 54 +-- .../mcp/openapi/mapper/McpToolMapper.java | 11 + .../mapper/impl/OpenApiToMcpToolMapper.java | 413 ++++++++++++++++++ .../mapper/impl/SwaggerToMcpToolMapper.java | 287 ++++++++++++ ...entFactory.java => HttpClientManager.java} | 17 +- .../openapi/rest/RestApiExecutionService.java | 16 +- .../tool/OpenApiToMcpToolConverter.java | 381 +--------------- 8 files changed, 767 insertions(+), 426 deletions(-) create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/McpToolMapper.java create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapper.java create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java rename src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/{HttpClientFactory.java => HttpClientManager.java} (75%) diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java index ad3c380e..dd9fd975 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.oracle.mcp.openapi.cache.McpServerCacheService; import com.oracle.mcp.openapi.fetcher.OpenApiSchemaFetcher; -import com.oracle.mcp.openapi.rest.HttpClientFactory; +import com.oracle.mcp.openapi.rest.HttpClientManager; import com.oracle.mcp.openapi.rest.RestApiExecutionService; import com.oracle.mcp.openapi.tool.OpenApiToMcpToolConverter; @@ -15,7 +15,6 @@ @Configuration public class OpenApiMcpServerConfiguration { - @Bean("jsonMapper") public ObjectMapper jsonMapper() { return new ObjectMapper(); @@ -41,17 +40,12 @@ public OpenApiToMcpToolConverter openApiToMcpToolConverter(McpServerCacheService return new OpenApiToMcpToolConverter(mcpServerCacheService); } - @Bean - public OpenApiSchemaFetcher openApiDefinitionFetcher(RestApiExecutionService restApiExecutionService,@Qualifier("jsonMapper") ObjectMapper jsonMapper, @Qualifier("yamlMapper") ObjectMapper yamlMapper){ + public OpenApiSchemaFetcher openApiDefinitionFetcher(RestApiExecutionService restApiExecutionService, + @Qualifier("jsonMapper") ObjectMapper jsonMapper, + @Qualifier("yamlMapper") ObjectMapper yamlMapper){ return new OpenApiSchemaFetcher(restApiExecutionService,jsonMapper,yamlMapper); } - @Bean - public HttpClientFactory httpClientFactory(){ - return new HttpClientFactory(); - } - - } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java index 7d0a3712..a2b080fc 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java @@ -3,7 +3,6 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.oracle.mcp.openapi.enums.OpenApiSchemaAuthType; -import com.oracle.mcp.openapi.exception.OpenApiException; import com.oracle.mcp.openapi.model.McpServerConfig; import com.oracle.mcp.openapi.enums.OpenApiSchemaSourceType; import com.oracle.mcp.openapi.enums.OpenApiSchemaType; @@ -12,6 +11,9 @@ import org.slf4j.LoggerFactory; import java.io.IOException; +import java.io.InputStream; +import java.net.HttpURLConnection; +import java.net.URL; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; @@ -52,47 +54,31 @@ private String loadFromFile(McpServerConfig mcpServerConfig) throws IOException return Files.readString(path, StandardCharsets.UTF_8); } - private String downloadContent(McpServerConfig mcpServerConfig) throws OpenApiException { - try { - String url = mcpServerConfig.getSpecUrl(); - // You can also build headers here via applyAuth if you prefer - Map headers = applyAuth(mcpServerConfig); + private String downloadContent(McpServerConfig mcpServerConfig) throws Exception { + URL url = new URL(mcpServerConfig.getSpecUrl()); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.setConnectTimeout(10_000); + conn.setReadTimeout(10_000); + conn.setRequestMethod("GET"); - return restApiExecutionService.get(url, headers); + applyAuth(conn, mcpServerConfig); - } catch (IOException | InterruptedException e) { - String errorMessage = "Failed to download OpenAPI schema."; - LOGGER.error(errorMessage, e); - throw new OpenApiException(errorMessage, e); + try (InputStream in = conn.getInputStream()) { + byte[] bytes = in.readAllBytes(); + return new String(bytes, StandardCharsets.UTF_8); } } - - private Map applyAuth(McpServerConfig mcpServerConfig) { - Map headers = new HashMap<>(); + private void applyAuth(HttpURLConnection conn, McpServerConfig mcpServerConfig) { OpenApiSchemaAuthType authType = mcpServerConfig.getAuthType(); - if (authType == null || authType == OpenApiSchemaAuthType.NONE) { - return headers; - } - - switch (authType) { - case BASIC -> { - String encoded = Base64.getEncoder().encodeToString( - (mcpServerConfig.getAuthUsername() + ":" + mcpServerConfig.getAuthPassword()) - .getBytes(StandardCharsets.UTF_8) - ); - headers.put("Authorization", "Basic " + encoded); - } - - case BEARER -> { - headers.put("Authorization", "Bearer " + mcpServerConfig.getAuthToken()); - } - default -> { - // NONE or unrecognized - } + if (authType == OpenApiSchemaAuthType.BASIC) { + String encoded = Base64.getEncoder().encodeToString( + (mcpServerConfig.getAuthUsername() + ":" + mcpServerConfig.getAuthPassword()) + .getBytes(StandardCharsets.UTF_8) + ); + conn.setRequestProperty("Authorization", "Basic " + encoded); } - return headers; } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/McpToolMapper.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/McpToolMapper.java new file mode 100644 index 00000000..0fd464fc --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/McpToolMapper.java @@ -0,0 +1,11 @@ +package com.oracle.mcp.openapi.mapper; + +import com.fasterxml.jackson.databind.JsonNode; +import io.modelcontextprotocol.spec.McpSchema; + +import java.util.List; + +public interface McpToolMapper { + + List convert(JsonNode apiSpec); +} diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapper.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapper.java new file mode 100644 index 00000000..56b301e6 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapper.java @@ -0,0 +1,413 @@ +package com.oracle.mcp.openapi.mapper.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.oracle.mcp.openapi.cache.McpServerCacheService; +import com.oracle.mcp.openapi.mapper.McpToolMapper; +import io.modelcontextprotocol.spec.McpSchema; +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.MediaType; +import io.swagger.v3.oas.models.media.Schema; +import io.swagger.v3.oas.models.parameters.Parameter; +import io.swagger.v3.oas.models.responses.ApiResponse; +import io.swagger.v3.parser.OpenAPIV3Parser; +import io.swagger.v3.parser.core.models.ParseOptions; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; + +public class OpenApiToMcpToolMapper implements McpToolMapper { + + private final Set usedNames = new HashSet<>(); + private final McpServerCacheService mcpServerCacheService; + private static final Logger LOGGER = LoggerFactory.getLogger(OpenApiToMcpToolMapper.class); + + public OpenApiToMcpToolMapper(McpServerCacheService mcpServerCacheService) { + this.mcpServerCacheService = mcpServerCacheService; + } + + @Override + public List convert(JsonNode openApiJson) { + LOGGER.debug("Parsing OpenAPI JsonNode to OpenAPI object..."); + OpenAPI openAPI = parseOpenApi(openApiJson); + + if (openAPI.getPaths() == null || openAPI.getPaths().isEmpty()) { + throw new IllegalArgumentException("'paths' object not found in the specification."); + } + + List mcpTools = processPaths(openAPI); + LOGGER.debug("Conversion complete. Total tools created: {}", mcpTools.size()); + updateToolsToCache(mcpTools); + return mcpTools; + } + + private List processPaths(OpenAPI openAPI) { + List mcpTools = new ArrayList<>(); + + for (Map.Entry pathEntry : openAPI.getPaths().entrySet()) { + String path = pathEntry.getKey(); + PathItem pathItem = pathEntry.getValue(); + if (pathItem == null) continue; + + processOperationsForPath(path, pathItem, mcpTools); + } + return mcpTools; + } + + private void processOperationsForPath(String path, PathItem pathItem, List mcpTools) { + for (Map.Entry methodEntry : pathItem.readOperationsMap().entrySet()) { + PathItem.HttpMethod method = methodEntry.getKey(); + Operation operation = methodEntry.getValue(); + if (operation == null) continue; + + McpSchema.Tool tool = buildToolFromOperation(path, method, operation); + if (tool != null) { + mcpTools.add(tool); + } + } + } + + private McpSchema.Tool buildToolFromOperation(String path, PathItem.HttpMethod method, Operation operation) { + String rawOperationId = (operation.getOperationId() != null && !operation.getOperationId().isEmpty()) + ? operation.getOperationId() + : toCamelCase(method.name() + " " + path); + + String toolName = makeUniqueName(rawOperationId); + LOGGER.debug("--- Parsing Operation: {} {} (ID: {}) ---", method.name().toUpperCase(), path, toolName); + + String toolTitle = (operation.getSummary() != null && !operation.getSummary().isEmpty()) + ? operation.getSummary() + : toolName; + String toolDescription = getDescription(toolName, method.name(), path, operation); + + Map properties = new LinkedHashMap<>(); + List requiredParams = new ArrayList<>(); + + Map> pathParams = new HashMap<>(); + Map> queryParams = new HashMap<>(); + extractPathAndQueryParams(operation, pathParams, queryParams, properties, requiredParams); + + extractRequestBody(operation, properties, requiredParams); + + McpSchema.JsonSchema inputSchema = new McpSchema.JsonSchema( + "object", + properties.isEmpty() ? null : properties, + requiredParams.isEmpty() ? null : requiredParams, + false, null, null + ); + Map outputSchema = extractOutputSchema(operation); + outputSchema.put("additionalProperties", true); + + Map meta = buildMeta(method, path, operation, pathParams, queryParams); + + return McpSchema.Tool.builder() + .title(toolTitle) + .name(toolName) + .description(toolDescription) + .inputSchema(inputSchema) + .outputSchema(outputSchema) + .meta(meta) + .build(); + } + + + private Map buildMeta(PathItem.HttpMethod method, String path, + Operation operation, Map> pathParams, + Map> queryParams) { + Map meta = new LinkedHashMap<>(); + meta.put("httpMethod", method.name()); + meta.put("path", path); + if (operation.getTags() != null) { + meta.put("tags", operation.getTags()); + } + if (operation.getSecurity() != null) { + meta.put("security", operation.getSecurity()); + } + if (!pathParams.isEmpty()) { + meta.put("pathParams", pathParams); + } + if (!queryParams.isEmpty()) { + meta.put("queryParams", queryParams); + } + return meta; + } + + // Modified helper method to populate all relevant collections + private void extractPathAndQueryParams(Operation operation, + Map> pathParams, + Map> queryParams, + Map properties, + List requiredParams) { + if (operation.getParameters() != null) { + for (Parameter param : operation.getParameters()) { + if (param.getName() == null || param.getSchema() == null) continue; + + // This part is for your meta block (original behavior) + Map paramMeta = parameterMetaMap(param); + if ("path".equalsIgnoreCase(param.getIn())) { + pathParams.put(param.getName(), paramMeta); + } else if ("query".equalsIgnoreCase(param.getIn())) { + queryParams.put(param.getName(), paramMeta); + } + + // This part is for your inputSchema + if ("path".equalsIgnoreCase(param.getIn()) || "query".equalsIgnoreCase(param.getIn())) { + + // --- MODIFICATION START --- + + // 1. Get the base schema for the parameter + Map paramSchema = extractInputSchema(param.getSchema()); + + // 2. Manually add the description from the Parameter object itself + if (param.getDescription() != null && !param.getDescription().isEmpty()) { + paramSchema.put("description", param.getDescription()); + } + + // 3. Add the complete schema (with description) to the properties map + properties.put(param.getName(), paramSchema); + + // --- MODIFICATION END --- + + + // If required, add it to the list of required parameters + if (Boolean.TRUE.equals(param.getRequired())) { + requiredParams.add(param.getName()); + } + } + } + } + } + + private void updateToolsToCache(List tools) { + for (McpSchema.Tool tool : tools) { + mcpServerCacheService.putTool(tool.name(), tool); + } + } + + public String getDescription(String toolName, String httpMethod, String path, Operation operation) { + StringBuilder doc = new StringBuilder(); + + if (operation.getSummary() != null && !operation.getSummary().isEmpty()) { + doc.append(operation.getSummary()).append("\n"); + } else if (operation.getDescription() != null && !operation.getDescription().isEmpty()) { + doc.append(operation.getDescription()).append("\n"); + } +// +// doc.append("HTTP Method : ").append(httpMethod.toUpperCase()).append("\n"); +// doc.append("End URL : ").append(" `").append(path).append("`\n"); +// +// if (operation.getParameters() != null && !operation.getParameters().isEmpty()) { +// appendParameterList(doc, "Path parameters: ", operation.getParameters(), "path"); +// appendParameterList(doc, "Query parameters: ", operation.getParameters(), "query"); +// } +// +// if (operation.getRequestBody() != null) { +// doc.append("Request body: ") +// .append(Boolean.TRUE.equals(operation.getRequestBody().getRequired()) ? "Required" : "Optional") +// .append("\n"); +// } +// +// appendExampleUsage(doc, toolName); + return doc.toString(); + } + + private void appendParameterList(StringBuilder doc, String label, List parameters, String type) { + doc.append(label); + for (Parameter p : parameters) { + if (type.equals(p.getIn())) { + doc.append(p.getName()) + .append(Boolean.TRUE.equals(p.getRequired()) ? "*" : "") + .append(","); + } + } + doc.append("\n"); + } + + private void appendExampleUsage(StringBuilder doc, String toolName) { + doc.append("Example usage: "); + doc.append("```json\n"); + doc.append("{\n"); + doc.append(" \"tool_name\": \"").append(toolName).append("\",\n"); + doc.append(" \"arguments\": {}\n"); + doc.append("}\n"); + doc.append("```\n"); + } + + private OpenAPI parseOpenApi(JsonNode jsonNode) { + String jsonString = jsonNode.toString(); + ParseOptions options = new ParseOptions(); + options.setResolve(true); + options.setResolveFully(true); + return new OpenAPIV3Parser().readContents(jsonString, null, options).getOpenAPI(); + } + + public McpSchema.JsonSchema buildInputSchemaFromOperation(Operation operation) { + Map properties = new LinkedHashMap<>(); + List requiredParams = new ArrayList<>(); + + extractRequestBody(operation, properties, requiredParams); + + // This now assumes McpSchema.JsonSchema is defined elsewhere in your project + return new McpSchema.JsonSchema( + "object", + properties.isEmpty() ? null : properties, + requiredParams.isEmpty() ? null : requiredParams, + false, null, null + ); + } + + private void extractRequestBody(Operation operation, Map properties, List requiredParams) { + // Extract from request body + if (operation.getRequestBody() != null && operation.getRequestBody().getContent() != null) { + // Assuming a single JSON media type for simplicity + MediaType media = operation.getRequestBody().getContent().get("application/json"); + if (media != null && media.getSchema() != null) { + Schema bodySchema = media.getSchema(); + + if ("object".equals(bodySchema.getType()) && bodySchema.getProperties() != null) { + bodySchema.getProperties().forEach((name, schema) -> { + properties.put(name.toString(), extractInputSchema((Schema) schema)); + }); + if (bodySchema.getRequired() != null) { + requiredParams.addAll(bodySchema.getRequired()); + } + } + } + } + } + + private Map extractInputSchema(Schema openApiSchema) { + if (openApiSchema == null) { + return new LinkedHashMap<>(); + } + + Map jsonSchema = new LinkedHashMap<>(); + + // Copy basic properties + if (openApiSchema.getType() != null) jsonSchema.put("type", openApiSchema.getType()); + if (openApiSchema.getDescription() != null) { + jsonSchema.put("description", openApiSchema.getDescription()); + } + if (openApiSchema.getFormat() != null) jsonSchema.put("format", openApiSchema.getFormat()); + if (openApiSchema.getEnum() != null) jsonSchema.put("enum", openApiSchema.getEnum()); + + // --- Recursive Handling --- + + // If it's an object, process its properties recursively + if ("object".equals(openApiSchema.getType())) { + if (openApiSchema.getProperties() != null) { + Map nestedProperties = new LinkedHashMap<>(); + openApiSchema.getProperties().forEach((key, value) -> { + nestedProperties.put(key.toString(), extractInputSchema((Schema) value)); + }); + jsonSchema.put("properties", nestedProperties); + } + if (openApiSchema.getRequired() != null) { + jsonSchema.put("required", openApiSchema.getRequired()); + } + } + + // If it's an array, process its 'items' schema recursively + if ("array".equals(openApiSchema.getType())) { + if (openApiSchema.getItems() != null) { + jsonSchema.put("items", extractInputSchema(openApiSchema.getItems())); + } + } + + return jsonSchema; + } + + + public Map extractOutputSchema(Operation operation) { + if (operation.getResponses() == null || operation.getResponses().isEmpty()) { + return new HashMap<>(); + } + + ApiResponse response = operation.getResponses().get("200"); + if (response == null) { + response = operation.getResponses().get("default"); + } + if (response == null || response.getContent() == null || !response.getContent().containsKey("application/json")) { + return new HashMap<>(); + } + + Schema schema = response.getContent().get("application/json").getSchema(); + if (schema == null) { + return new HashMap<>(); + } + return extractOutputSchema(schema); + } + + public Map extractOutputSchema(Schema schema) { + if (schema == null) { + return Collections.emptyMap(); + } + + Map jsonSchema = new LinkedHashMap<>(); + + // Copy basic properties + if (schema.getType() != null) { + jsonSchema.put("type", new String[]{schema.getType(), "null"}); + } + if (schema.getDescription() != null) { + jsonSchema.put("description", schema.getDescription()); + } + + // If it's an object, recursively process its properties + if ("object".equals(schema.getType()) && schema.getProperties() != null) { + Map properties = new LinkedHashMap<>(); + for (Map.Entry entry : schema.getProperties().entrySet()) { + properties.put(entry.getKey(), extractOutputSchema(entry.getValue())); + } + jsonSchema.put("properties", properties); + } + + // Add other properties you may need, like "required", "items" for arrays, etc. + if (schema.getRequired() != null) { + jsonSchema.put("required", schema.getRequired()); + } + + + return jsonSchema; + } + + private Map parameterMetaMap(Parameter p) { + Map paramMeta = new LinkedHashMap<>(); + paramMeta.put("name", p.getName()); + paramMeta.put("required", Boolean.TRUE.equals(p.getRequired())); + if (p.getDescription() != null) { + paramMeta.put("description", p.getDescription()); + } + if (p.getSchema() != null && p.getSchema().getType() != null) { + paramMeta.put("type", p.getSchema().getType()); + } + return paramMeta; + } + + private String makeUniqueName(String base) { + String name = base; + int counter = 1; + while (usedNames.contains(name)) { + name = base + "_" + counter++; + } + usedNames.add(name); + return name; + } + + private static String toCamelCase(String input) { + String[] parts = input.split("[^a-zA-Z0-9]+"); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < parts.length; i++) { + if (parts[i].isEmpty()) continue; + String word = parts[i].toLowerCase(); + if (i == 0) { + sb.append(word); + } else { + sb.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1)); + } + } + return sb.toString(); + } +} diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java new file mode 100644 index 00000000..b7c48a7e --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java @@ -0,0 +1,287 @@ +package com.oracle.mcp.openapi.mapper.impl; + +import com.fasterxml.jackson.databind.JsonNode; +import com.oracle.mcp.openapi.cache.McpServerCacheService; +import com.oracle.mcp.openapi.mapper.McpToolMapper; +import io.modelcontextprotocol.spec.McpSchema; +import io.swagger.models.*; +import io.swagger.models.parameters.BodyParameter; +import io.swagger.models.parameters.Parameter; +import io.swagger.models.parameters.QueryParameter; +import io.swagger.models.parameters.PathParameter; +import io.swagger.models.properties.Property; +import io.swagger.models.properties.RefProperty; +import io.swagger.models.properties.ArrayProperty; +import io.swagger.models.properties.ObjectProperty; +import io.swagger.parser.SwaggerParser; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; + +public class SwaggerToMcpToolMapper implements McpToolMapper { + + private final Set usedNames = new HashSet<>(); + private final McpServerCacheService mcpServerCacheService; + private static final Logger LOGGER = LoggerFactory.getLogger(SwaggerToMcpToolMapper.class); + + public SwaggerToMcpToolMapper(McpServerCacheService mcpServerCacheService) { + this.mcpServerCacheService = mcpServerCacheService; + } + + @Override + public List convert(JsonNode swaggerJson) { + LOGGER.debug("Parsing Swagger 2 JsonNode to Swagger object..."); + Swagger swagger = parseSwagger(swaggerJson); + + if (swagger.getPaths() == null || swagger.getPaths().isEmpty()) { + throw new IllegalArgumentException("'paths' object not found in the specification."); + } + + List mcpTools = processPaths(swagger); + LOGGER.debug("Conversion complete. Total tools created: {}", mcpTools.size()); + updateToolsToCache(mcpTools); + return mcpTools; + } + + private List processPaths(Swagger swagger) { + List mcpTools = new ArrayList<>(); + + for (Map.Entry pathEntry : swagger.getPaths().entrySet()) { + String path = pathEntry.getKey(); + Path pathItem = pathEntry.getValue(); + if (pathItem == null) continue; + + processOperationsForPath(path, pathItem, mcpTools); + } + return mcpTools; + } + + private void processOperationsForPath(String path, Path pathItem, List mcpTools) { + Map operations = pathItem.getOperationMap(); + if (operations == null) return; + + for (Map.Entry methodEntry : operations.entrySet()) { + HttpMethod method = methodEntry.getKey(); + Operation operation = methodEntry.getValue(); + if (operation == null) continue; + + McpSchema.Tool tool = buildToolFromOperation(path, method, operation); + if (tool != null) { + mcpTools.add(tool); + } + } + } + + private McpSchema.Tool buildToolFromOperation(String path, HttpMethod method, Operation operation) { + String rawOperationId = (operation.getOperationId() != null && !operation.getOperationId().isEmpty()) + ? operation.getOperationId() + : toCamelCase(method.name() + " " + path); + + String toolName = makeUniqueName(rawOperationId); + LOGGER.debug("--- Parsing Operation: {} {} (ID: {}) ---", method.name(), path, toolName); + + String toolTitle = (operation.getSummary() != null && !operation.getSummary().isEmpty()) + ? operation.getSummary() + : toolName; + String toolDescription = getDescription(toolName, method.name(), path, operation); + + Map properties = new LinkedHashMap<>(); + List requiredParams = new ArrayList<>(); + + Map> pathParams = new HashMap<>(); + Map> queryParams = new HashMap<>(); + extractPathAndQueryParams(operation, pathParams, queryParams, properties, requiredParams); + + extractRequestBody(operation, properties, requiredParams); + + McpSchema.JsonSchema inputSchema = new McpSchema.JsonSchema( + "object", + properties.isEmpty() ? null : properties, + requiredParams.isEmpty() ? null : requiredParams, + false, null, null + ); + Map outputSchema = extractOutputSchema(operation); + outputSchema.put("additionalProperties", true); + + Map meta = buildMeta(method, path, operation, pathParams, queryParams); + + return McpSchema.Tool.builder() + .title(toolTitle) + .name(toolName) + .description(toolDescription) + .inputSchema(inputSchema) + .outputSchema(outputSchema) + .meta(meta) + .build(); + } + + private Map buildMeta(HttpMethod method, String path, + Operation operation, Map> pathParams, + Map> queryParams) { + Map meta = new LinkedHashMap<>(); + meta.put("httpMethod", method.name()); + meta.put("path", path); + if (operation.getTags() != null) { + meta.put("tags", operation.getTags()); + } + if (operation.getSecurity() != null) { + meta.put("security", operation.getSecurity()); + } + if (!pathParams.isEmpty()) { + meta.put("pathParams", pathParams); + } + if (!queryParams.isEmpty()) { + meta.put("queryParams", queryParams); + } + return meta; + } + + private void extractPathAndQueryParams(Operation operation, + Map> pathParams, + Map> queryParams, + Map properties, + List requiredParams) { + if (operation.getParameters() != null) { + for (Parameter param : operation.getParameters()) { + if (param.getName() == null) continue; + + Map paramMeta = new LinkedHashMap<>(); + paramMeta.put("name", param.getName()); + paramMeta.put("required", param.getRequired()); + if (param.getDescription() != null) { + paramMeta.put("description", param.getDescription()); + } + + if (param instanceof PathParameter) { + pathParams.put(param.getName(), paramMeta); + } else if (param instanceof QueryParameter) { + queryParams.put(param.getName(), paramMeta); + } + + Map paramSchema = new LinkedHashMap<>(); + if (param.getDescription() != null) { + paramSchema.put("description", param.getDescription()); + } + paramSchema.put("type", "string"); // fallback for Swagger 2 simple params + properties.put(param.getName(), paramSchema); + + if (param.getRequired()) { + requiredParams.add(param.getName()); + } + } + } + } + + private void extractRequestBody(Operation operation, Map properties, List requiredParams) { + if (operation.getParameters() != null) { + for (Parameter param : operation.getParameters()) { + if (param instanceof BodyParameter) { + Model schema = ((BodyParameter) param).getSchema(); + if (schema instanceof ModelImpl) { + ModelImpl impl = (ModelImpl) schema; + if (impl.getProperties() != null) { + for (Map.Entry entry : impl.getProperties().entrySet()) { + properties.put(entry.getKey(), extractPropertySchema(entry.getValue())); + } + } + if (impl.getRequired() != null) { + requiredParams.addAll(impl.getRequired()); + } + } + } + } + } + } + + private Map extractPropertySchema(Property property) { + Map schema = new LinkedHashMap<>(); + if (property.getType() != null) schema.put("type", property.getType()); + if (property.getDescription() != null) schema.put("description", property.getDescription()); + + if (property instanceof ObjectProperty) { + Map nestedProps = new LinkedHashMap<>(); + ObjectProperty objProp = (ObjectProperty) property; + if (objProp.getProperties() != null) { + for (Map.Entry entry : objProp.getProperties().entrySet()) { + nestedProps.put(entry.getKey(), extractPropertySchema(entry.getValue())); + } + } + schema.put("properties", nestedProps); + } + + if (property instanceof ArrayProperty) { + ArrayProperty arrProp = (ArrayProperty) property; + schema.put("type", "array"); + schema.put("items", extractPropertySchema(arrProp.getItems())); + } + + if (property instanceof RefProperty) { + schema.put("$ref", ((RefProperty) property).get$ref()); + } + + return schema; + } + + public Map extractOutputSchema(Operation operation) { + if (operation.getResponses() == null || operation.getResponses().isEmpty()) { + return new HashMap<>(); + } + + Response response = operation.getResponses().get("200"); + if (response == null) { + response = operation.getResponses().get("default"); + } + if (response == null || response.getSchema() == null) { + return new HashMap<>(); + } + + return extractPropertySchema(response.getSchema()); + } + + private void updateToolsToCache(List tools) { + for (McpSchema.Tool tool : tools) { + mcpServerCacheService.putTool(tool.name(), tool); + } + } + + public String getDescription(String toolName, String httpMethod, String path, Operation operation) { + StringBuilder doc = new StringBuilder(); + if (operation.getSummary() != null && !operation.getSummary().isEmpty()) { + doc.append(operation.getSummary()).append("\n"); + } else if (operation.getDescription() != null && !operation.getDescription().isEmpty()) { + doc.append(operation.getDescription()).append("\n"); + } + return doc.toString(); + } + + private Swagger parseSwagger(JsonNode jsonNode) { + String jsonString = jsonNode.toString(); + return new SwaggerParser().parse(jsonString); + } + + private String makeUniqueName(String base) { + String name = base; + int counter = 1; + while (usedNames.contains(name)) { + name = base + "_" + counter++; + } + usedNames.add(name); + return name; + } + + private static String toCamelCase(String input) { + String[] parts = input.split("[^a-zA-Z0-9]+"); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < parts.length; i++) { + if (parts[i].isEmpty()) continue; + String word = parts[i].toLowerCase(); + if (i == 0) { + sb.append(word); + } else { + sb.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1)); + } + } + return sb.toString(); + } +} diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/HttpClientFactory.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/HttpClientManager.java similarity index 75% rename from src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/HttpClientFactory.java rename to src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/HttpClientManager.java index aedc7517..05b6b449 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/HttpClientFactory.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/HttpClientManager.java @@ -7,10 +7,10 @@ import java.net.http.HttpClient; import java.time.Duration; -public class HttpClientFactory { +public class HttpClientManager { - public HttpClientFactory() { + public HttpClientManager() { } @@ -23,15 +23,10 @@ public HttpClient getClient(McpServerConfig config) { if (config.getHttpVersion() != null) { - switch (config.getHttpVersion().toUpperCase()) { - case "HTTP_1_1": - builder.version(HttpClient.Version.HTTP_1_1); - break; - case "HTTP_2": - builder.version(HttpClient.Version.HTTP_2); - break; - default: - throw new IllegalArgumentException("Unsupported HTTP version: " + config.getHttpVersion()); + if (config.getHttpVersion().equalsIgnoreCase("HTTP_2")) { + builder.version(HttpClient.Version.HTTP_2); + } else { + builder.version(HttpClient.Version.HTTP_1_1); } } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiExecutionService.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiExecutionService.java index 75c5557d..226bc27b 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiExecutionService.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiExecutionService.java @@ -15,7 +15,8 @@ public class RestApiExecutionService { - private final HttpClient httpClient; + private HttpClient httpClient; + private final McpServerCacheService mcpServerCacheService; // HTTP Method constants public static final String GET = "GET"; @@ -26,8 +27,16 @@ public class RestApiExecutionService { public RestApiExecutionService(McpServerCacheService mcpServerCacheService) { + this.mcpServerCacheService = mcpServerCacheService; + } + + private HttpClient getHttpClient() { + if(this.httpClient!=null){ + return this.httpClient; + } McpServerConfig mcpServerConfig = mcpServerCacheService.getServerConfig(); - this.httpClient = new HttpClientFactory().getClient(mcpServerConfig); + return new HttpClientManager().getClient(mcpServerConfig); + } /** @@ -46,6 +55,7 @@ public String executeRequest(String targetUrl, String method, String body, Map response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + HttpResponse response = getHttpClient().send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); return response.body(); } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiToMcpToolConverter.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiToMcpToolConverter.java index 6c78b505..5b000a1f 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiToMcpToolConverter.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiToMcpToolConverter.java @@ -2,16 +2,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.oracle.mcp.openapi.cache.McpServerCacheService; +import com.oracle.mcp.openapi.mapper.impl.OpenApiToMcpToolMapper; +import com.oracle.mcp.openapi.mapper.impl.SwaggerToMcpToolMapper; import io.modelcontextprotocol.spec.McpSchema; -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.MediaType; -import io.swagger.v3.oas.models.media.Schema; -import io.swagger.v3.oas.models.parameters.Parameter; -import io.swagger.v3.oas.models.responses.ApiResponse; -import io.swagger.v3.parser.OpenAPIV3Parser; -import io.swagger.v3.parser.core.models.ParseOptions; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -19,7 +12,6 @@ public class OpenApiToMcpToolConverter { - private final Set usedNames = new HashSet<>(); private final McpServerCacheService mcpServerCacheService; private static final Logger LOGGER = LoggerFactory.getLogger(OpenApiToMcpToolConverter.class); @@ -29,154 +21,13 @@ public OpenApiToMcpToolConverter(McpServerCacheService mcpServerCacheService) { public List convertJsonToMcpTools(JsonNode openApiJson) { LOGGER.debug("Parsing OpenAPI JsonNode to OpenAPI object..."); - OpenAPI openAPI = parseOpenApi(openApiJson); + List mcpTools = parseApi(openApiJson); - if (openAPI.getPaths() == null || openAPI.getPaths().isEmpty()) { - throw new IllegalArgumentException("'paths' object not found in the specification."); - } - - List mcpTools = processPaths(openAPI); LOGGER.debug("Conversion complete. Total tools created: {}", mcpTools.size()); updateToolsToCache(mcpTools); return mcpTools; } - private List processPaths(OpenAPI openAPI) { - List mcpTools = new ArrayList<>(); - - for (Map.Entry pathEntry : openAPI.getPaths().entrySet()) { - String path = pathEntry.getKey(); - PathItem pathItem = pathEntry.getValue(); - if (pathItem == null) continue; - - processOperationsForPath(path, pathItem, mcpTools); - } - return mcpTools; - } - - private void processOperationsForPath(String path, PathItem pathItem, List mcpTools) { - for (Map.Entry methodEntry : pathItem.readOperationsMap().entrySet()) { - PathItem.HttpMethod method = methodEntry.getKey(); - Operation operation = methodEntry.getValue(); - if (operation == null) continue; - - McpSchema.Tool tool = buildToolFromOperation(path, method, operation); - if (tool != null) { - mcpTools.add(tool); - } - } - } - - private McpSchema.Tool buildToolFromOperation(String path, PathItem.HttpMethod method, Operation operation) { - String rawOperationId = (operation.getOperationId() != null && !operation.getOperationId().isEmpty()) - ? operation.getOperationId() - : toCamelCase(method.name() + " " + path); - - String toolName = makeUniqueName(rawOperationId); - LOGGER.debug("--- Parsing Operation: {} {} (ID: {}) ---", method.name().toUpperCase(), path, toolName); - - String toolTitle = (operation.getSummary() != null && !operation.getSummary().isEmpty()) - ? operation.getSummary() - : toolName; - String toolDescription = getDescription(toolName, method.name(), path, operation); - - Map properties = new LinkedHashMap<>(); - List requiredParams = new ArrayList<>(); - - Map> pathParams = new HashMap<>(); - Map> queryParams = new HashMap<>(); - extractPathAndQueryParams(operation, pathParams, queryParams, properties, requiredParams); - - extractRequestBody(operation, properties, requiredParams); - - McpSchema.JsonSchema inputSchema = new McpSchema.JsonSchema( - "object", - properties.isEmpty() ? null : properties, - requiredParams.isEmpty() ? null : requiredParams, - false, null, null - ); - Map outputSchema = extractOutputSchema(operation); - outputSchema.put("additionalProperties", true); - - Map meta = buildMeta(method, path, operation, pathParams, queryParams); - - return McpSchema.Tool.builder() - .title(toolTitle) - .name(toolName) - .description(toolDescription) - .inputSchema(inputSchema) - .outputSchema(outputSchema) - .meta(meta) - .build(); - } - - - private Map buildMeta(PathItem.HttpMethod method, String path, - Operation operation, Map> pathParams, - Map> queryParams) { - Map meta = new LinkedHashMap<>(); - meta.put("httpMethod", method.name()); - meta.put("path", path); - if (operation.getTags() != null) { - meta.put("tags", operation.getTags()); - } - if (operation.getSecurity() != null) { - meta.put("security", operation.getSecurity()); - } - if (!pathParams.isEmpty()) { - meta.put("pathParams", pathParams); - } - if (!queryParams.isEmpty()) { - meta.put("queryParams", queryParams); - } - return meta; - } - - // Modified helper method to populate all relevant collections - private void extractPathAndQueryParams(Operation operation, - Map> pathParams, - Map> queryParams, - Map properties, - List requiredParams) { - if (operation.getParameters() != null) { - for (Parameter param : operation.getParameters()) { - if (param.getName() == null || param.getSchema() == null) continue; - - // This part is for your meta block (original behavior) - Map paramMeta = parameterMetaMap(param); - if ("path".equalsIgnoreCase(param.getIn())) { - pathParams.put(param.getName(), paramMeta); - } else if ("query".equalsIgnoreCase(param.getIn())) { - queryParams.put(param.getName(), paramMeta); - } - - // This part is for your inputSchema - if ("path".equalsIgnoreCase(param.getIn()) || "query".equalsIgnoreCase(param.getIn())) { - - // --- MODIFICATION START --- - - // 1. Get the base schema for the parameter - Map paramSchema = extractInputSchema(param.getSchema()); - - // 2. Manually add the description from the Parameter object itself - if (param.getDescription() != null && !param.getDescription().isEmpty()) { - paramSchema.put("description", param.getDescription()); - } - - // 3. Add the complete schema (with description) to the properties map - properties.put(param.getName(), paramSchema); - - // --- MODIFICATION END --- - - - // If required, add it to the list of required parameters - if (Boolean.TRUE.equals(param.getRequired())) { - requiredParams.add(param.getName()); - } - } - } - } - } private void updateToolsToCache(List tools) { for (McpSchema.Tool tool : tools) { @@ -184,229 +35,23 @@ private void updateToolsToCache(List tools) { } } - public String getDescription(String toolName, String httpMethod, String path, Operation operation) { - StringBuilder doc = new StringBuilder(); - - if (operation.getSummary() != null && !operation.getSummary().isEmpty()) { - doc.append(operation.getSummary()).append("\n"); - } else if (operation.getDescription() != null && !operation.getDescription().isEmpty()) { - doc.append(operation.getDescription()).append("\n"); - } -// -// doc.append("HTTP Method : ").append(httpMethod.toUpperCase()).append("\n"); -// doc.append("End URL : ").append(" `").append(path).append("`\n"); -// -// if (operation.getParameters() != null && !operation.getParameters().isEmpty()) { -// appendParameterList(doc, "Path parameters: ", operation.getParameters(), "path"); -// appendParameterList(doc, "Query parameters: ", operation.getParameters(), "query"); -// } -// -// if (operation.getRequestBody() != null) { -// doc.append("Request body: ") -// .append(Boolean.TRUE.equals(operation.getRequestBody().getRequired()) ? "Required" : "Optional") -// .append("\n"); -// } -// -// appendExampleUsage(doc, toolName); - return doc.toString(); - } - - private void appendParameterList(StringBuilder doc, String label, List parameters, String type) { - doc.append(label); - for (Parameter p : parameters) { - if (type.equals(p.getIn())) { - doc.append(p.getName()) - .append(Boolean.TRUE.equals(p.getRequired()) ? "*" : "") - .append(","); - } - } - doc.append("\n"); - } - - private void appendExampleUsage(StringBuilder doc, String toolName) { - doc.append("Example usage: "); - doc.append("```json\n"); - doc.append("{\n"); - doc.append(" \"tool_name\": \"").append(toolName).append("\",\n"); - doc.append(" \"arguments\": {}\n"); - doc.append("}\n"); - doc.append("```\n"); - } - - private OpenAPI parseOpenApi(JsonNode jsonNode) { - String jsonString = jsonNode.toString(); - ParseOptions options = new ParseOptions(); - options.setResolve(true); - options.setResolveFully(true); - return new OpenAPIV3Parser().readContents(jsonString, null, options).getOpenAPI(); - } - - public McpSchema.JsonSchema buildInputSchemaFromOperation(Operation operation) { - Map properties = new LinkedHashMap<>(); - List requiredParams = new ArrayList<>(); - - extractRequestBody(operation, properties, requiredParams); - - // This now assumes McpSchema.JsonSchema is defined elsewhere in your project - return new McpSchema.JsonSchema( - "object", - properties.isEmpty() ? null : properties, - requiredParams.isEmpty() ? null : requiredParams, - false, null, null - ); - } - - private void extractRequestBody(Operation operation, Map properties, List requiredParams) { - // Extract from request body - if (operation.getRequestBody() != null && operation.getRequestBody().getContent() != null) { - // Assuming a single JSON media type for simplicity - MediaType media = operation.getRequestBody().getContent().get("application/json"); - if (media != null && media.getSchema() != null) { - Schema bodySchema = media.getSchema(); - - if ("object".equals(bodySchema.getType()) && bodySchema.getProperties() != null) { - bodySchema.getProperties().forEach((name, schema) -> { - properties.put(name.toString(), extractInputSchema((Schema) schema)); - }); - if (bodySchema.getRequired() != null) { - requiredParams.addAll(bodySchema.getRequired()); - } - } - } - } - } - - private Map extractInputSchema(Schema openApiSchema) { - if (openApiSchema == null) { - return new LinkedHashMap<>(); + private List parseApi(JsonNode jsonNode) { + if (jsonNode == null) { + throw new IllegalArgumentException("jsonNode cannot be null"); } + // Detect version + if (jsonNode.has("openapi")) { - Map jsonSchema = new LinkedHashMap<>(); - // Copy basic properties - if (openApiSchema.getType() != null) jsonSchema.put("type", openApiSchema.getType()); - if (openApiSchema.getDescription() != null){ - jsonSchema.put("description", openApiSchema.getDescription()); - } - if (openApiSchema.getFormat() != null) jsonSchema.put("format", openApiSchema.getFormat()); - if (openApiSchema.getEnum() != null) jsonSchema.put("enum", openApiSchema.getEnum()); - - // --- Recursive Handling --- - - // If it's an object, process its properties recursively - if ("object".equals(openApiSchema.getType())) { - if (openApiSchema.getProperties() != null) { - Map nestedProperties = new LinkedHashMap<>(); - openApiSchema.getProperties().forEach((key, value) -> { - nestedProperties.put(key.toString(), extractInputSchema((Schema) value)); - }); - jsonSchema.put("properties", nestedProperties); - } - if (openApiSchema.getRequired() != null) { - jsonSchema.put("required", openApiSchema.getRequired()); - } - } - - // If it's an array, process its 'items' schema recursively - if ("array".equals(openApiSchema.getType())) { - if (openApiSchema.getItems() != null) { - jsonSchema.put("items", extractInputSchema(openApiSchema.getItems())); - } - } + return new OpenApiToMcpToolMapper(mcpServerCacheService).convert(jsonNode); - return jsonSchema; - } - - - public Map extractOutputSchema(Operation operation) { - if (operation.getResponses() == null || operation.getResponses().isEmpty()) { - return new HashMap<>(); - } + } else if (jsonNode.has("swagger")) { - ApiResponse response = operation.getResponses().get("200"); - if (response == null) { - response = operation.getResponses().get("default"); - } - if (response == null || response.getContent() == null || !response.getContent().containsKey("application/json")) { - return new HashMap<>(); - } + return new SwaggerToMcpToolMapper(mcpServerCacheService).convert(jsonNode); - Schema schema = response.getContent().get("application/json").getSchema(); - if (schema == null) { - return new HashMap<>(); + } else { + throw new IllegalArgumentException("Unsupported API definition: missing 'openapi' or 'swagger' field"); } - return extractOutputSchema(schema); } - public Map extractOutputSchema(Schema schema) { - if (schema == null) { - return Collections.emptyMap(); - } - - Map jsonSchema = new LinkedHashMap<>(); - - // Copy basic properties - if (schema.getType() != null) { - jsonSchema.put("type", new String[]{schema.getType(), "null"}); - } - if (schema.getDescription() != null) { - jsonSchema.put("description", schema.getDescription()); - } - - // If it's an object, recursively process its properties - if ("object".equals(schema.getType()) && schema.getProperties() != null) { - Map properties = new LinkedHashMap<>(); - for (Map.Entry entry : schema.getProperties().entrySet()) { - properties.put(entry.getKey(), extractOutputSchema(entry.getValue())); - } - jsonSchema.put("properties", properties); - } - - // Add other properties you may need, like "required", "items" for arrays, etc. - if (schema.getRequired() != null) { - jsonSchema.put("required", schema.getRequired()); - } - - - - return jsonSchema; - } - - private Map parameterMetaMap(Parameter p) { - Map paramMeta = new LinkedHashMap<>(); - paramMeta.put("name", p.getName()); - paramMeta.put("required", Boolean.TRUE.equals(p.getRequired())); - if (p.getDescription() != null) { - paramMeta.put("description", p.getDescription()); - } - if (p.getSchema() != null && p.getSchema().getType() != null) { - paramMeta.put("type", p.getSchema().getType()); - } - return paramMeta; - } - - private String makeUniqueName(String base) { - String name = base; - int counter = 1; - while (usedNames.contains(name)) { - name = base + "_" + counter++; - } - usedNames.add(name); - return name; - } - - private static String toCamelCase(String input) { - String[] parts = input.split("[^a-zA-Z0-9]+"); - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < parts.length; i++) { - if (parts[i].isEmpty()) continue; - String word = parts[i].toLowerCase(); - if (i == 0) { - sb.append(word); - } else { - sb.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1)); - } - } - return sb.toString(); - } } From a41d33e06a793ad6a19b13250933941f224f329f Mon Sep 17 00:00:00 2001 From: Joby Wilson Mathews Date: Sun, 24 Aug 2025 15:27:55 +0530 Subject: [PATCH 04/12] Added Default log path , if no log path is specified --- .../mcp/openapi/config/OpenApiMcpServerConfiguration.java | 1 - .../com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java | 2 -- src/openapi-mcp-server/src/main/resources/logback.xml | 4 ++-- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java index dd9fd975..49249c87 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java @@ -4,7 +4,6 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.oracle.mcp.openapi.cache.McpServerCacheService; import com.oracle.mcp.openapi.fetcher.OpenApiSchemaFetcher; -import com.oracle.mcp.openapi.rest.HttpClientManager; import com.oracle.mcp.openapi.rest.RestApiExecutionService; import com.oracle.mcp.openapi.tool.OpenApiToMcpToolConverter; diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java index a2b080fc..8f186561 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java @@ -19,8 +19,6 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Base64; -import java.util.HashMap; -import java.util.Map; public class OpenApiSchemaFetcher { diff --git a/src/openapi-mcp-server/src/main/resources/logback.xml b/src/openapi-mcp-server/src/main/resources/logback.xml index a6bf0d3b..814661ae 100644 --- a/src/openapi-mcp-server/src/main/resources/logback.xml +++ b/src/openapi-mcp-server/src/main/resources/logback.xml @@ -1,5 +1,5 @@ - + @@ -10,7 +10,7 @@ - ${LOG_PATH}/app.log + ${LOG_PATH:-${java.io.tmpdir}}/app.log ${LOG_PATH}/app-%d{yyyy-MM-dd}.%i.log 10MB From 77458235ebee6013361d20d51e49fa931e231b87 Mon Sep 17 00:00:00 2001 From: Joby Wilson Mathews Date: Fri, 29 Aug 2025 00:24:06 +0530 Subject: [PATCH 05/12] Error handling initial commit --- src/openapi-mcp-server/pom.xml | 27 +++- .../oracle/mcp/openapi/OpenApiMcpServer.java | 138 +++++++++--------- .../mcp/openapi/constants/ErrorMessage.java | 5 + .../enums/OpenApiSchemaSourceType.java | 2 +- .../McpServerToolExecutionException.java | 13 ++ .../McpServerToolInitializeException.java | 13 ++ .../openapi/exception/OpenApiException.java | 13 -- .../openapi/fetcher/OpenApiSchemaFetcher.java | 55 ++++++- .../mcp/openapi/model/McpServerConfig.java | 25 ++-- 9 files changed, 187 insertions(+), 104 deletions(-) create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/ErrorMessage.java create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/McpServerToolExecutionException.java create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/McpServerToolInitializeException.java delete mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/OpenApiException.java diff --git a/src/openapi-mcp-server/pom.xml b/src/openapi-mcp-server/pom.xml index 8b942ba9..4d1f643f 100644 --- a/src/openapi-mcp-server/pom.xml +++ b/src/openapi-mcp-server/pom.xml @@ -12,7 +12,7 @@ 21 21 UTF-8 - 3.3.0 + 3.5.5 @@ -58,8 +58,31 @@ compile - + + + com.squareup.okhttp3 + okhttp + test + 5.1.0 + + + + io.modelcontextprotocol.sdk + mcp-test + 0.11.3 + test + + + + org.springframework.boot + spring-boot-starter-test + ${spring.boot.version} + test + + + + diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java index 5c1778cc..7dd9551d 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java @@ -5,11 +5,13 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.oracle.mcp.openapi.cache.McpServerCacheService; import com.oracle.mcp.openapi.enums.OpenApiSchemaAuthType; +import com.oracle.mcp.openapi.exception.McpServerToolInitializeException; import com.oracle.mcp.openapi.fetcher.OpenApiSchemaFetcher; import com.oracle.mcp.openapi.rest.RestApiExecutionService; import com.oracle.mcp.openapi.model.McpServerConfig; import com.oracle.mcp.openapi.tool.OpenApiToMcpToolConverter; import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpServerTransportProvider; @@ -58,19 +60,16 @@ public static void main(String[] args) { } @Override - public void run(String... args) { - if (args.length == 0) { - System.err.println("Usage: java -jar app.jar "); - return; - } - + public void run(String... args) throws Exception { + initialize(args); } - @Bean - public CommandLineRunner commandRunner() { - return args -> { - // No latch, register immediately - McpServerConfig argument = McpServerConfig.fromArgs(args); + + private void initialize(String[] args) throws Exception { + // No latch, register immediately + McpServerConfig argument = null; + try { + argument = McpServerConfig.fromArgs(args); mcpServerCacheService.putServerConfig(argument); // Fetch and convert OpenAPI to tools JsonNode openApiJson = openApiSchemaFetcher.fetch(argument); @@ -96,69 +95,72 @@ public CommandLineRunner commandRunner() { for (McpSchema.Tool tool : mcpTools) { SyncToolSpecification syncTool = SyncToolSpecification.builder() .tool(tool) - .callHandler((exchange, callRequest) -> { - String response=""; - try { - McpSchema.Tool toolToExecute = mcpServerCacheService.getTool(callRequest.name()); - String httpMethod = toolToExecute.meta().get("httpMethod").toString(); - String path = toolToExecute.meta().get("path").toString(); - - McpServerConfig config = mcpServerCacheService.getServerConfig(); - String url = config.getApiBaseUrl() + path; - Map arguments =callRequest.arguments(); - Map> pathParams = (Map>) Optional.ofNullable(toolToExecute.meta().get("pathParams")) - .orElse(Collections.emptyMap()); - Map> queryParams = (Map>) Optional.ofNullable(toolToExecute.meta().get("queryParams")) - .orElse(Collections.emptyMap()); - String formattedUrl = url; - Iterator> iterator = arguments.entrySet().iterator(); - LOGGER.debug("Path params {}", pathParams); - while (iterator.hasNext()) { - Map.Entry entry = iterator.next(); - if (pathParams.containsKey(entry.getKey())) { - LOGGER.info("Entry {}", new ObjectMapper().writeValueAsString(entry)); - - String placeholder = "{" + entry.getKey() + "}"; - String value = entry.getValue() != null ? entry.getValue().toString() : ""; - formattedUrl = formattedUrl.replace(placeholder, value); - iterator.remove(); - } - } - LOGGER.info("Formated URL {}", formattedUrl); - - OpenApiSchemaAuthType authType = config.getAuthType(); - Map headers = new java.util.HashMap<>(); - if (authType == OpenApiSchemaAuthType.BASIC) { - String encoded = Base64.getEncoder().encodeToString( - (config.getAuthUsername() + ":" + config.getAuthPassword()) - .getBytes(StandardCharsets.UTF_8) - ); - headers.put("Authorization", "Basic " + encoded); - } - String body = new ObjectMapper().writeValueAsString(arguments); - response = restApiExecutionService.executeRequest(formattedUrl,httpMethod,body,headers); - LOGGER.info("Server exchange {}", new ObjectMapper().writeValueAsString(toolToExecute)); - LOGGER.info("Server callRequest {}", new ObjectMapper().writeValueAsString(callRequest)); - - } catch (JsonProcessingException | InterruptedException e) { - throw new RuntimeException(e); - } catch (IOException e) { - throw new RuntimeException(e); - } - return McpSchema.CallToolResult.builder() - .structuredContent(response) - .build(); - }) + .callHandler(this::executeTool) .build(); mcpSyncServer.addTool(syncTool); } DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) context.getBeanFactory(); - BeanDefinitionBuilder beanDefinitionBuilder = BeanDefinitionBuilder - .genericBeanDefinition(McpSyncServer.class); + beanFactory.registerSingleton("mcpSyncServer", mcpSyncServer); + } catch (McpServerToolInitializeException exception) { + LOGGER.error(exception.getMessage()); + System.err.println(exception.getMessage()); + System.exit(1); + } - beanFactory.registerBeanDefinition("mcpSyncServer", beanDefinitionBuilder.getBeanDefinition()); + } - }; + private McpSchema.CallToolResult executeTool(McpSyncServerExchange exchange, McpSchema.CallToolRequest callRequest){ + String response=""; + try { + McpSchema.Tool toolToExecute = mcpServerCacheService.getTool(callRequest.name()); + String httpMethod = toolToExecute.meta().get("httpMethod").toString(); + String path = toolToExecute.meta().get("path").toString(); + + McpServerConfig config = mcpServerCacheService.getServerConfig(); + String url = config.getApiBaseUrl() + path; + Map arguments =callRequest.arguments(); + Map> pathParams = (Map>) Optional.ofNullable(toolToExecute.meta().get("pathParams")) + .orElse(Collections.emptyMap()); + Map> queryParams = (Map>) Optional.ofNullable(toolToExecute.meta().get("queryParams")) + .orElse(Collections.emptyMap()); + String formattedUrl = url; + Iterator> iterator = arguments.entrySet().iterator(); + LOGGER.debug("Path params {}", pathParams); + while (iterator.hasNext()) { + Map.Entry entry = iterator.next(); + if (pathParams.containsKey(entry.getKey())) { + LOGGER.info("Entry {}", new ObjectMapper().writeValueAsString(entry)); + + String placeholder = "{" + entry.getKey() + "}"; + String value = entry.getValue() != null ? entry.getValue().toString() : ""; + formattedUrl = formattedUrl.replace(placeholder, value); + iterator.remove(); + } + } + LOGGER.info("Formated URL {}", formattedUrl); + + OpenApiSchemaAuthType authType = config.getAuthType(); + Map headers = new java.util.HashMap<>(); + if (authType == OpenApiSchemaAuthType.BASIC) { + String encoded = Base64.getEncoder().encodeToString( + (config.getAuthUsername() + ":" + config.getAuthPassword()) + .getBytes(StandardCharsets.UTF_8) + ); + headers.put("Authorization", "Basic " + encoded); + } + String body = new ObjectMapper().writeValueAsString(arguments); + response = restApiExecutionService.executeRequest(formattedUrl,httpMethod,body,headers); + LOGGER.info("Server exchange {}", new ObjectMapper().writeValueAsString(toolToExecute)); + LOGGER.info("Server callRequest {}", new ObjectMapper().writeValueAsString(callRequest)); + + } catch (JsonProcessingException | InterruptedException e) { + throw new RuntimeException(e); + } catch (IOException e) { + throw new RuntimeException(e); + } + return McpSchema.CallToolResult.builder() + .structuredContent(response) + .build(); } } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/ErrorMessage.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/ErrorMessage.java new file mode 100644 index 00000000..fd91226f --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/ErrorMessage.java @@ -0,0 +1,5 @@ +package com.oracle.mcp.openapi.constants; + +public interface ErrorMessage { + String MISSING_API_SPEC = "API specification not provided. Please pass --api-spec or set the API_SPEC environment variable."; +} diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaSourceType.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaSourceType.java index 6490ecf6..7c5d518d 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaSourceType.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaSourceType.java @@ -7,7 +7,7 @@ public enum OpenApiSchemaSourceType { public static OpenApiSchemaSourceType getType(McpServerConfig request) { // Backward compatibility: prefer specUrl if set - String specUrl = request.getSpecUrl(); + String specUrl = request.getApiSpec(); if (specUrl != null && !specUrl.trim().isEmpty()) { return URL; } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/McpServerToolExecutionException.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/McpServerToolExecutionException.java new file mode 100644 index 00000000..56e403e9 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/McpServerToolExecutionException.java @@ -0,0 +1,13 @@ +package com.oracle.mcp.openapi.exception; + +public class McpServerToolExecutionException extends Exception{ + + + public McpServerToolExecutionException(String message) { + super(message); + } + + public McpServerToolExecutionException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/McpServerToolInitializeException.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/McpServerToolInitializeException.java new file mode 100644 index 00000000..f37a0016 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/McpServerToolInitializeException.java @@ -0,0 +1,13 @@ +package com.oracle.mcp.openapi.exception; + +public class McpServerToolInitializeException extends Exception{ + + + public McpServerToolInitializeException(String message) { + super(message); + } + + public McpServerToolInitializeException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/OpenApiException.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/OpenApiException.java deleted file mode 100644 index 67a95a9f..00000000 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/OpenApiException.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.oracle.mcp.openapi.exception; - -public class OpenApiException extends Exception{ - - - public OpenApiException(String message) { - super(message); - } - - public OpenApiException(String message, Throwable cause) { - super(message, cause); - } -} diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java index 8f186561..5a475c6b 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java @@ -14,10 +14,13 @@ import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URL; +import java.nio.ByteBuffer; +import java.nio.CharBuffer; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.Arrays; import java.util.Base64; public class OpenApiSchemaFetcher { @@ -54,7 +57,7 @@ private String loadFromFile(McpServerConfig mcpServerConfig) throws IOException private String downloadContent(McpServerConfig mcpServerConfig) throws Exception { - URL url = new URL(mcpServerConfig.getSpecUrl()); + URL url = new URL(mcpServerConfig.getApiSpec()); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); conn.setConnectTimeout(10_000); conn.setReadTimeout(10_000); @@ -70,12 +73,52 @@ private String downloadContent(McpServerConfig mcpServerConfig) throws Exception private void applyAuth(HttpURLConnection conn, McpServerConfig mcpServerConfig) { OpenApiSchemaAuthType authType = mcpServerConfig.getAuthType(); - if (authType == OpenApiSchemaAuthType.BASIC) { - String encoded = Base64.getEncoder().encodeToString( - (mcpServerConfig.getAuthUsername() + ":" + mcpServerConfig.getAuthPassword()) - .getBytes(StandardCharsets.UTF_8) - ); + if (authType != OpenApiSchemaAuthType.BASIC) { + return; + } + + String username = mcpServerConfig.getAuthUsername(); + char[] passwordChars = mcpServerConfig.getAuthPassword(); + + if (username == null || passwordChars == null) { + System.err.println("Username or password is not configured for Basic Auth."); + return; + } + + // This will hold the "username:password" bytes + byte[] credentialsBytes = null; + // This will hold a temporary copy of the password as bytes + byte[] passwordBytes = null; + + try { + // 2. Convert username to bytes. + byte[] usernameBytes = username.getBytes(StandardCharsets.UTF_8); + byte[] separator = {':'}; + + // Convert password char[] to byte[] for encoding. + ByteBuffer passwordByteBuffer = StandardCharsets.UTF_8.encode(CharBuffer.wrap(passwordChars)); + passwordBytes = new byte[passwordByteBuffer.remaining()]; + passwordByteBuffer.get(passwordBytes); + + // 3. Combine them all in a byte array, avoiding intermediate Strings. + credentialsBytes = new byte[usernameBytes.length + separator.length + passwordBytes.length]; + System.arraycopy(usernameBytes, 0, credentialsBytes, 0, usernameBytes.length); + System.arraycopy(separator, 0, credentialsBytes, usernameBytes.length, separator.length); + System.arraycopy(passwordBytes, 0, credentialsBytes, usernameBytes.length + separator.length, passwordBytes.length); + + // 4. Base64 encode the combined bytes and set the header. + String encoded = Base64.getEncoder().encodeToString(credentialsBytes); conn.setRequestProperty("Authorization", "Basic " + encoded); + + } finally { + // 5. IMPORTANT: Clear all sensitive arrays from memory. + Arrays.fill(passwordChars, '0'); + if (passwordBytes != null) { + Arrays.fill(passwordBytes, (byte) 0); + } + if (credentialsBytes != null) { + Arrays.fill(credentialsBytes, (byte) 0); + } } } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/McpServerConfig.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/McpServerConfig.java index f5d7db7c..ae48ef04 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/McpServerConfig.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/McpServerConfig.java @@ -1,6 +1,8 @@ package com.oracle.mcp.openapi.model; +import com.oracle.mcp.openapi.constants.ErrorMessage; import com.oracle.mcp.openapi.enums.OpenApiSchemaAuthType; +import com.oracle.mcp.openapi.exception.McpServerToolInitializeException; import java.util.HashMap; import java.util.Map; @@ -18,7 +20,6 @@ public final class McpServerConfig { private final String apiName; private final String apiBaseUrl; private final String apiSpec; - private final String specUrl; // Authentication details private final String authType; // raw string @@ -37,7 +38,7 @@ public final class McpServerConfig { private final String proxyHost; private final Integer proxyPort; - private McpServerConfig(String apiName, String apiBaseUrl, String specUrl, + private McpServerConfig(String apiName, String apiBaseUrl, String authType, char[] authToken, String authUsername, char[] authPassword, char[] authApiKey, String authApiKeyName, String authApiKeyIn, String apiSpec, @@ -46,7 +47,6 @@ private McpServerConfig(String apiName, String apiBaseUrl, String specUrl, String proxyHost, Integer proxyPort) { this.apiName = apiName; this.apiBaseUrl = apiBaseUrl; - this.specUrl = specUrl; this.authType = authType; this.authToken = authToken != null ? authToken.clone() : null; this.authUsername = authUsername; @@ -66,12 +66,14 @@ private McpServerConfig(String apiName, String apiBaseUrl, String specUrl, // ----------------- GETTERS ----------------- public String getApiName() { return apiName; } public String getApiBaseUrl() { return apiBaseUrl; } - public String getSpecUrl() { return specUrl; } public String getRawAuthType() { return authType; } public OpenApiSchemaAuthType getAuthType() { return OpenApiSchemaAuthType.getType(this); } public String getAuthUsername() { return authUsername; } public String getAuthToken() { return authToken != null ? new String(authToken) : null; } - public String getAuthPassword() { return authPassword != null ? new String(authPassword) : null; } + + public char[] getAuthPassword() { + return authPassword; + } public char[] getAuthApiKey() { return authApiKey != null ? authApiKey.clone() : null; } public String getAuthApiKeyName() { return authApiKeyName; } public String getAuthApiKeyIn() { return authApiKeyIn; } @@ -106,7 +108,7 @@ public long getResponseTimeoutMs() { public Integer getProxyPort() { return proxyPort; } // ----------------- FACTORY METHOD ----------------- - public static McpServerConfig fromArgs(String[] args) { + public static McpServerConfig fromArgs(String[] args) throws McpServerToolInitializeException { Map argMap = toMap(args); // ----------------- API info ----------------- @@ -114,14 +116,9 @@ public static McpServerConfig fromArgs(String[] args) { String apiBaseUrl = getStringValue(argMap.get("--api-base-url"), "API_BASE_URL"); String apiSpec = getStringValue(argMap.get("--api-spec"), "API_SPEC"); - String specUrl = getStringValue(argMap.get("--spec-url"), "API_SPEC_URL"); - if (specUrl == null && apiSpec == null) { - throw new IllegalArgumentException("Either --spec-url or --api-spec is required."); + if (apiSpec == null) { + throw new McpServerToolInitializeException(ErrorMessage.MISSING_API_SPEC); } - if (specUrl != null && apiSpec != null) { - throw new IllegalArgumentException("Provide either --spec-url or --api-spec, but not both."); - } - // ----------------- Authentication ----------------- String authType = getStringValue(argMap.get("--auth-type"), "AUTH_TYPE"); char[] authToken = getCharValue(argMap.get("--auth-token"), "AUTH_TOKEN"); @@ -147,7 +144,7 @@ public static McpServerConfig fromArgs(String[] args) { String proxyHost = getStringValue(argMap.get("--proxy-host"), "API_HTTP_PROXY_HOST"); Integer proxyPort = getIntOrNull(argMap.get("--proxy-port"), "API_HTTP_PROXY_PORT"); - return new McpServerConfig(apiName, apiBaseUrl, specUrl, + return new McpServerConfig(apiName, apiBaseUrl, authType, authToken, authUsername, authPassword, authApiKey, authApiKeyName, authApiKeyIn, apiSpec, connectTimeout, responseTimeout, From fec9712c5269c1629311ddda88a968701fda9beb Mon Sep 17 00:00:00 2001 From: Joby Wilson Mathews Date: Sun, 31 Aug 2025 13:20:01 +0530 Subject: [PATCH 06/12] Fixed issue related to unable to view tools in Claude --- src/openapi-mcp-server/pom.xml | 66 ++++++++++++------- .../mapper/impl/SwaggerToMcpToolMapper.java | 15 +++-- .../src/main/resources/logback.xml | 16 ++--- 3 files changed, 62 insertions(+), 35 deletions(-) diff --git a/src/openapi-mcp-server/pom.xml b/src/openapi-mcp-server/pom.xml index 4d1f643f..a0387081 100644 --- a/src/openapi-mcp-server/pom.xml +++ b/src/openapi-mcp-server/pom.xml @@ -1,7 +1,8 @@ + xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> + 4.0.0 com.oracle.mcp.openapi openapi-mcp-server @@ -13,85 +14,94 @@ 21 UTF-8 3.5.5 + 3.2.5 - + org.springframework.boot spring-boot-starter ${spring.boot.version} - + com.fasterxml.jackson.dataformat jackson-dataformat-yaml 2.19.2 - + io.modelcontextprotocol.sdk mcp 0.11.1 - + io.swagger.parser.v3 swagger-parser 2.1.31 - + + org.slf4j slf4j-api 2.0.7 - - ch.qos.logback logback-classic 1.5.18 - compile - - - - - com.squareup.okhttp3 - okhttp - test - 5.1.0 - + io.modelcontextprotocol.sdk mcp-test 0.11.3 test - + + org.springframework.boot spring-boot-starter-test ${spring.boot.version} test + + + + org.junit.vintage + junit-vintage-engine + + - + + + org.wiremock + wiremock + 3.13.1 + test + + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + - org.springframework.boot spring-boot-maven-plugin ${spring.boot.version} - @@ -99,9 +109,19 @@ + + + org.apache.maven.plugins + maven-surefire-plugin + 3.3.0 + + 0 + false + - \ No newline at end of file + + diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java index b7c48a7e..f39584f4 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java @@ -196,6 +196,15 @@ private void extractRequestBody(Operation operation, Map propert private Map extractPropertySchema(Property property) { Map schema = new LinkedHashMap<>(); + + if (property instanceof RefProperty) { + // Replace $ref with empty object + schema.put("type", "object"); + schema.put("properties", new LinkedHashMap<>()); + schema.put("additionalProperties", true); + return schema; + } + if (property.getType() != null) schema.put("type", property.getType()); if (property.getDescription() != null) schema.put("description", property.getDescription()); @@ -208,6 +217,7 @@ private Map extractPropertySchema(Property property) { } } schema.put("properties", nestedProps); + schema.put("additionalProperties", true); } if (property instanceof ArrayProperty) { @@ -216,13 +226,10 @@ private Map extractPropertySchema(Property property) { schema.put("items", extractPropertySchema(arrProp.getItems())); } - if (property instanceof RefProperty) { - schema.put("$ref", ((RefProperty) property).get$ref()); - } - return schema; } + public Map extractOutputSchema(Operation operation) { if (operation.getResponses() == null || operation.getResponses().isEmpty()) { return new HashMap<>(); diff --git a/src/openapi-mcp-server/src/main/resources/logback.xml b/src/openapi-mcp-server/src/main/resources/logback.xml index 814661ae..478055eb 100644 --- a/src/openapi-mcp-server/src/main/resources/logback.xml +++ b/src/openapi-mcp-server/src/main/resources/logback.xml @@ -1,14 +1,15 @@ - - - System.err + + + System.out %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + ${LOG_PATH:-${java.io.tmpdir}}/app.log @@ -22,14 +23,13 @@ - - - + + + - + - From 5899407a5c52a9ebc07f70a80f997b403a9d4395 Mon Sep 17 00:00:00 2001 From: Joby Wilson Mathews Date: Wed, 3 Sep 2025 14:41:08 +0530 Subject: [PATCH 07/12] Added Javadoc adn code refactoring --- src/openapi-mcp-server/pom.xml | 58 +-- .../oracle/mcp/openapi/OpenApiMcpServer.java | 146 ++++--- .../openapi/cache/McpServerCacheService.java | 41 +- .../config/OpenApiMcpServerConfiguration.java | 87 ++++- .../mcp/openapi/constants/ErrorMessage.java | 19 + .../openapi/enums/OpenApiSchemaAuthType.java | 44 ++- .../enums/OpenApiSchemaSourceType.java | 34 +- .../mcp/openapi/enums/OpenApiSchemaType.java | 40 +- .../McpServerToolExecutionException.java | 32 +- .../McpServerToolInitializeException.java | 33 +- .../oracle/mcp/openapi/fetcher/AuthType.java | 8 - .../openapi/fetcher/OpenApiSchemaFetcher.java | 97 ++++- .../mcp/openapi/mapper/McpToolMapper.java | 24 ++ .../mapper/impl/OpenApiToMcpToolMapper.java | 178 +++------ .../mapper/impl/SwaggerToMcpToolMapper.java | 297 +++++++++------ .../mcp/openapi/model/McpServerConfig.java | 356 ++++++++++++++---- .../mcp/openapi/rest/HttpClientManager.java | 43 ++- .../openapi/rest/RestApiExecutionService.java | 99 +++-- .../openapi/tool/OpenApiMcpToolExecutor.java | 238 ++++++++++++ .../tool/OpenApiMcpToolInitializer.java | 106 ++++++ .../tool/OpenApiToMcpToolConverter.java | 57 --- .../mcp/openapi/OpenApiMcpServerTest.java | 185 +++++++++ .../openapi/model/McpServerConfigTest.java | 213 +++++++++++ .../resources/__files/companies-response.json | 1 + .../resources/__files/company-1-response.json | 1 + .../__files/company-department-swagger.json | 232 ++++++++++++ .../mappings/comapny-department-swagger.json | 13 + .../test/resources/mappings/companies.json | 17 + .../resources/mappings/company-by-id.json | 18 + .../resources/mappings/create-company.json | 30 ++ .../resources/mappings/update-company.json | 22 ++ .../src/test/resources/tools/listTool.json | 1 + 32 files changed, 2225 insertions(+), 545 deletions(-) delete mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/AuthType.java create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutor.java create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolInitializer.java delete mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiToMcpToolConverter.java create mode 100644 src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/OpenApiMcpServerTest.java create mode 100644 src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/model/McpServerConfigTest.java create mode 100644 src/openapi-mcp-server/src/test/resources/__files/companies-response.json create mode 100644 src/openapi-mcp-server/src/test/resources/__files/company-1-response.json create mode 100644 src/openapi-mcp-server/src/test/resources/__files/company-department-swagger.json create mode 100644 src/openapi-mcp-server/src/test/resources/mappings/comapny-department-swagger.json create mode 100644 src/openapi-mcp-server/src/test/resources/mappings/companies.json create mode 100644 src/openapi-mcp-server/src/test/resources/mappings/company-by-id.json create mode 100644 src/openapi-mcp-server/src/test/resources/mappings/create-company.json create mode 100644 src/openapi-mcp-server/src/test/resources/mappings/update-company.json create mode 100644 src/openapi-mcp-server/src/test/resources/tools/listTool.json diff --git a/src/openapi-mcp-server/pom.xml b/src/openapi-mcp-server/pom.xml index a0387081..df7341db 100644 --- a/src/openapi-mcp-server/pom.xml +++ b/src/openapi-mcp-server/pom.xml @@ -6,30 +6,47 @@ 4.0.0 com.oracle.mcp.openapi openapi-mcp-server + Open API MCP Server + + The OpenAPI MCP Server is a Java-based implementation of an MCP (Model Context Protocol) server that works with OpenAPI specifications. It fetches OpenAPI schemas, converts them into MCP tools, and registers these tools with the MCP server. + 1.0-SNAPSHOT jar + + + Joby Mathews + joby.mathews@oracle.com + Oracle + https://www.oracle.com + https://github.com/jobyywilson + + Developer + + + + 21 21 UTF-8 - 3.5.5 + 3.2.5 3.2.5 + ${project.artifactId}-${project.version} - + org.springframework.boot spring-boot-starter ${spring.boot.version} - com.fasterxml.jackson.dataformat jackson-dataformat-yaml - 2.19.2 + 2.17.0 @@ -39,26 +56,24 @@ 0.11.1 - io.swagger.parser.v3 swagger-parser - 2.1.31 + 2.1.22 - org.slf4j slf4j-api - 2.0.7 + 2.0.13 + ch.qos.logback logback-classic 1.5.18 - io.modelcontextprotocol.sdk mcp-test @@ -66,14 +81,12 @@ test - org.springframework.boot spring-boot-starter-test ${spring.boot.version} test - org.junit.vintage junit-vintage-engine @@ -81,11 +94,10 @@ - org.wiremock wiremock - 3.13.1 + 3.5.2 test @@ -97,6 +109,7 @@ + ${jar.finalName} org.springframework.boot @@ -110,18 +123,17 @@ - - - org.apache.maven.plugins - maven-surefire-plugin - 3.3.0 - - 0 - false - - + + + release + + ${project.artifactId}-1.0 + + + + diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java index 7dd9551d..29feda76 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java @@ -1,38 +1,50 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ package com.oracle.mcp.openapi; -import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.oracle.mcp.openapi.cache.McpServerCacheService; -import com.oracle.mcp.openapi.enums.OpenApiSchemaAuthType; import com.oracle.mcp.openapi.exception.McpServerToolInitializeException; import com.oracle.mcp.openapi.fetcher.OpenApiSchemaFetcher; -import com.oracle.mcp.openapi.rest.RestApiExecutionService; import com.oracle.mcp.openapi.model.McpServerConfig; -import com.oracle.mcp.openapi.tool.OpenApiToMcpToolConverter; +import com.oracle.mcp.openapi.tool.OpenApiMcpToolExecutor; +import com.oracle.mcp.openapi.tool.OpenApiMcpToolInitializer; import io.modelcontextprotocol.server.McpServer; -import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; import io.modelcontextprotocol.spec.McpSchema; import io.modelcontextprotocol.spec.McpServerTransportProvider; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.beans.factory.support.BeanDefinitionBuilder; import org.springframework.beans.factory.support.DefaultListableBeanFactory; import org.springframework.boot.CommandLineRunner; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification; import io.modelcontextprotocol.server.McpSyncServer; - import org.springframework.context.ConfigurableApplicationContext; -import org.springframework.context.annotation.Bean; -import java.io.IOException; -import java.nio.charset.StandardCharsets; import java.util.*; +/** + * Entry point for the OpenAPI MCP server. + *

+ * This Spring Boot application: + *

    + *
  • Parses command-line arguments into a {@link McpServerConfig}
  • + *
  • Fetches an OpenAPI/Swagger specification
  • + *
  • Converts the specification into {@link McpSchema.Tool} objects
  • + *
  • Registers the tools in an {@link McpSyncServer}
  • + *
  • Exposes the server via standard I/O transport
  • + *
+ * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ @SpringBootApplication public class OpenApiMcpServer implements CommandLineRunner { @@ -40,42 +52,69 @@ public class OpenApiMcpServer implements CommandLineRunner { OpenApiSchemaFetcher openApiSchemaFetcher; @Autowired - OpenApiToMcpToolConverter openApiToMcpToolConverter; + OpenApiMcpToolExecutor openApiMcpToolExecutor; @Autowired - ConfigurableApplicationContext context; + OpenApiMcpToolInitializer openApiMcpToolInitializer; @Autowired - McpServerCacheService mcpServerCacheService; + ConfigurableApplicationContext context; @Autowired - RestApiExecutionService restApiExecutionService; + McpServerCacheService mcpServerCacheService; private static final Logger LOGGER = LoggerFactory.getLogger(OpenApiMcpServer.class); - - + /** + * Starts the Spring Boot application. + * + * @param args command-line arguments for configuring the server + */ public static void main(String[] args) { SpringApplication.run(OpenApiMcpServer.class, args); } + /** + * Callback method executed after the Spring Boot application starts. + *

+ * Delegates to {@link #initialize(String[])} to set up the MCP server. + * + * @param args application command-line arguments + * @throws Exception if initialization fails + */ @Override public void run(String... args) throws Exception { initialize(args); } - + /** + * Initializes the MCP server. + *

+ * The initialization process: + *

    + *
  1. Parses arguments into {@link McpServerConfig}
  2. + *
  3. Caches the server configuration
  4. + *
  5. Fetches and parses the OpenAPI/Swagger schema
  6. + *
  7. Converts the schema into MCP tools via {@link OpenApiMcpToolInitializer}
  8. + *
  9. Builds and configures an {@link McpSyncServer}
  10. + *
  11. Registers each tool with a {@link SyncToolSpecification}
  12. + *
  13. Registers the MCP server bean in the Spring context
  14. + *
+ * + * @param args command-line arguments + * @throws Exception if initialization fails + */ private void initialize(String[] args) throws Exception { - // No latch, register immediately - McpServerConfig argument = null; + McpServerConfig argument; try { argument = McpServerConfig.fromArgs(args); mcpServerCacheService.putServerConfig(argument); + // Fetch and convert OpenAPI to tools JsonNode openApiJson = openApiSchemaFetcher.fetch(argument); - List mcpTools = openApiToMcpToolConverter.convertJsonToMcpTools(openApiJson); + List mcpTools = openApiMcpToolInitializer.extractTools(openApiJson); - // Build MCP server + // Build MCP server capabilities McpSchema.ServerCapabilities serverCapabilities = McpSchema.ServerCapabilities.builder() .tools(false) .resources(false, false) @@ -84,6 +123,7 @@ private void initialize(String[] args) throws Exception { .completions() .build(); + // Use stdin/stdout for communication McpServerTransportProvider stdInOutTransport = new StdioServerTransportProvider(new ObjectMapper(), System.in, System.out); @@ -91,76 +131,24 @@ private void initialize(String[] args) throws Exception { .serverInfo("openapi-mcp-server", "1.0.0") .capabilities(serverCapabilities) .build(); - // Register tools + + // Register each tool in the server for (McpSchema.Tool tool : mcpTools) { SyncToolSpecification syncTool = SyncToolSpecification.builder() .tool(tool) - .callHandler(this::executeTool) + .callHandler(openApiMcpToolExecutor::execute) .build(); mcpSyncServer.addTool(syncTool); } - DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) context.getBeanFactory(); + // Expose MCP server as a Spring bean + DefaultListableBeanFactory beanFactory = (DefaultListableBeanFactory) context.getBeanFactory(); beanFactory.registerSingleton("mcpSyncServer", mcpSyncServer); + } catch (McpServerToolInitializeException exception) { LOGGER.error(exception.getMessage()); System.err.println(exception.getMessage()); System.exit(1); } - - } - - private McpSchema.CallToolResult executeTool(McpSyncServerExchange exchange, McpSchema.CallToolRequest callRequest){ - String response=""; - try { - McpSchema.Tool toolToExecute = mcpServerCacheService.getTool(callRequest.name()); - String httpMethod = toolToExecute.meta().get("httpMethod").toString(); - String path = toolToExecute.meta().get("path").toString(); - - McpServerConfig config = mcpServerCacheService.getServerConfig(); - String url = config.getApiBaseUrl() + path; - Map arguments =callRequest.arguments(); - Map> pathParams = (Map>) Optional.ofNullable(toolToExecute.meta().get("pathParams")) - .orElse(Collections.emptyMap()); - Map> queryParams = (Map>) Optional.ofNullable(toolToExecute.meta().get("queryParams")) - .orElse(Collections.emptyMap()); - String formattedUrl = url; - Iterator> iterator = arguments.entrySet().iterator(); - LOGGER.debug("Path params {}", pathParams); - while (iterator.hasNext()) { - Map.Entry entry = iterator.next(); - if (pathParams.containsKey(entry.getKey())) { - LOGGER.info("Entry {}", new ObjectMapper().writeValueAsString(entry)); - - String placeholder = "{" + entry.getKey() + "}"; - String value = entry.getValue() != null ? entry.getValue().toString() : ""; - formattedUrl = formattedUrl.replace(placeholder, value); - iterator.remove(); - } - } - LOGGER.info("Formated URL {}", formattedUrl); - - OpenApiSchemaAuthType authType = config.getAuthType(); - Map headers = new java.util.HashMap<>(); - if (authType == OpenApiSchemaAuthType.BASIC) { - String encoded = Base64.getEncoder().encodeToString( - (config.getAuthUsername() + ":" + config.getAuthPassword()) - .getBytes(StandardCharsets.UTF_8) - ); - headers.put("Authorization", "Basic " + encoded); - } - String body = new ObjectMapper().writeValueAsString(arguments); - response = restApiExecutionService.executeRequest(formattedUrl,httpMethod,body,headers); - LOGGER.info("Server exchange {}", new ObjectMapper().writeValueAsString(toolToExecute)); - LOGGER.info("Server callRequest {}", new ObjectMapper().writeValueAsString(callRequest)); - - } catch (JsonProcessingException | InterruptedException e) { - throw new RuntimeException(e); - } catch (IOException e) { - throw new RuntimeException(e); - } - return McpSchema.CallToolResult.builder() - .structuredContent(response) - .build(); } } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/cache/McpServerCacheService.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/cache/McpServerCacheService.java index 77a5e148..c777d336 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/cache/McpServerCacheService.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/cache/McpServerCacheService.java @@ -1,3 +1,9 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ package com.oracle.mcp.openapi.cache; import com.oracle.mcp.openapi.model.McpServerConfig; @@ -6,26 +12,57 @@ import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; +/** + * A thread-safe, in-memory cache to store the server configuration and parsed OpenAPI tools. + *

+ * This service acts as a simple singleton-like container for shared resources that are + * required throughout the server's lifecycle, preventing the need to re-parse or + * re-initialize these objects for each request. + * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ public class McpServerCacheService { private final ConcurrentMap toolListCache = new ConcurrentHashMap<>(); private McpServerConfig serverConfig; - // ===== MCP TOOL LIST ===== + /** + * Caches a tool definition, associating it with a unique key (e.g., the operation ID). + * If a tool with the same key already exists, it will be overwritten. + * + * @param key The unique identifier for the tool. + * @param tool The {@link McpSchema.Tool} object to cache. + */ public void putTool(String key, McpSchema.Tool tool) { toolListCache.put(key, tool); } + /** + * Retrieves a tool from the cache by its key. + * + * @param key The unique identifier of the tool to retrieve. + * @return The cached {@link McpSchema.Tool} object, or {@code null} if no tool is found for the given key. + */ public McpSchema.Tool getTool(String key) { return toolListCache.get(key); } - // ===== MCP SERVER CONFIG (single instance) ===== + /** + * Caches the server's global configuration object. + * This will overwrite any previously stored configuration. + * + * @param config The {@link McpServerConfig} object to cache. + */ public void putServerConfig(McpServerConfig config) { serverConfig = config; } + /** + * Retrieves the cached server configuration object. + * + * @return The cached {@link McpServerConfig} object. + */ public McpServerConfig getServerConfig() { return serverConfig; } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java index 49249c87..42f047c7 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java @@ -1,3 +1,9 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ package com.oracle.mcp.openapi.config; import com.fasterxml.jackson.databind.ObjectMapper; @@ -5,46 +11,105 @@ import com.oracle.mcp.openapi.cache.McpServerCacheService; import com.oracle.mcp.openapi.fetcher.OpenApiSchemaFetcher; import com.oracle.mcp.openapi.rest.RestApiExecutionService; -import com.oracle.mcp.openapi.tool.OpenApiToMcpToolConverter; +import com.oracle.mcp.openapi.tool.OpenApiMcpToolExecutor; +import com.oracle.mcp.openapi.tool.OpenApiMcpToolInitializer; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +/** + * Spring configuration class responsible for defining and wiring all the necessary beans + * for the OpenAPI MCP server application. + *

+ * This class centralizes the creation of services, mappers, and other components, + * managing their dependencies through Spring's dependency injection framework. + * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ @Configuration public class OpenApiMcpServerConfiguration { + /** + * Creates a singleton {@link ObjectMapper} bean for handling JSON serialization and deserialization. + * + * @return A new {@code ObjectMapper} instance. + */ @Bean("jsonMapper") public ObjectMapper jsonMapper() { return new ObjectMapper(); } + /** + * Creates a singleton {@link ObjectMapper} bean specifically configured for handling YAML. + * + * @return A new {@code ObjectMapper} instance configured with a {@link YAMLFactory}. + */ @Bean("yamlMapper") public ObjectMapper yamlMapper() { return new ObjectMapper(new YAMLFactory()); } + /** + * Creates a singleton {@link McpServerCacheService} bean to act as an in-memory + * cache for the server configuration and parsed tools. + * + * @return A new {@code McpServerCacheService} instance. + */ @Bean - public McpServerCacheService mcpServerCacheService(){ + public McpServerCacheService mcpServerCacheService() { return new McpServerCacheService(); } + /** + * Creates a singleton {@link RestApiExecutionService} bean responsible for executing + * HTTP requests against the target OpenAPI. + * + * @param mcpServerCacheService The cache service, used to retrieve server and auth configurations. + * @return A new {@code RestApiExecutionService} instance. + */ @Bean - public RestApiExecutionService restApiExecutionService(McpServerCacheService mcpServerCacheService){ + public RestApiExecutionService restApiExecutionService(McpServerCacheService mcpServerCacheService) { return new RestApiExecutionService(mcpServerCacheService); } + /** + * Creates a singleton {@link OpenApiMcpToolInitializer} bean that parses an OpenAPI + * specification and converts its operations into MCP tools. + * + * @param mcpServerCacheService The cache service where the converted tools will be stored. + * @return A new {@code OpenApiMcpToolInitializer} instance. + */ @Bean - public OpenApiToMcpToolConverter openApiToMcpToolConverter(McpServerCacheService mcpServerCacheService) { - return new OpenApiToMcpToolConverter(mcpServerCacheService); + public OpenApiMcpToolInitializer openApiToMcpToolConverter(McpServerCacheService mcpServerCacheService) { + return new OpenApiMcpToolInitializer(mcpServerCacheService); } + /** + * Creates a singleton {@link OpenApiSchemaFetcher} bean for retrieving the + * OpenAPI specification from a URL or local path. + * + * @param jsonMapper The mapper for parsing JSON-formatted specifications. + * @param yamlMapper The mapper for parsing YAML-formatted specifications. + * @return A new {@code OpenApiSchemaFetcher} instance. + */ @Bean - public OpenApiSchemaFetcher openApiDefinitionFetcher(RestApiExecutionService restApiExecutionService, - @Qualifier("jsonMapper") ObjectMapper jsonMapper, - @Qualifier("yamlMapper") ObjectMapper yamlMapper){ - return new OpenApiSchemaFetcher(restApiExecutionService,jsonMapper,yamlMapper); + public OpenApiSchemaFetcher openApiDefinitionFetcher(@Qualifier("jsonMapper") ObjectMapper jsonMapper, + @Qualifier("yamlMapper") ObjectMapper yamlMapper) { + return new OpenApiSchemaFetcher(jsonMapper, yamlMapper); } - -} + /** + * Creates a singleton {@link OpenApiMcpToolExecutor} bean that handles the + * execution of a specific MCP tool call by translating it into an HTTP request. + * + * @param mcpServerCacheService The cache service to look up tool definitions and server config. + * @param restApiExecutionService The service to execute the final HTTP request. + * @param jsonMapper The mapper to serialize the request body arguments to JSON. + * @return A new {@code OpenApiMcpToolExecutor} instance. + */ + @Bean + public OpenApiMcpToolExecutor openApiMcpToolExecutor(McpServerCacheService mcpServerCacheService, RestApiExecutionService restApiExecutionService, @Qualifier("jsonMapper") ObjectMapper jsonMapper) { + return new OpenApiMcpToolExecutor(mcpServerCacheService, restApiExecutionService, jsonMapper); + } +} \ No newline at end of file diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/ErrorMessage.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/ErrorMessage.java index fd91226f..617fddc2 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/ErrorMessage.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/ErrorMessage.java @@ -1,5 +1,24 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ package com.oracle.mcp.openapi.constants; + +/** + * Defines a collection of constant error message strings used throughout the application. + *

+ * This interface centralizes user-facing error messages to ensure consistency and + * ease of maintenance. By keeping them in one place, we can easily update or + * translate them without searching through the entire codebase. + * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ public interface ErrorMessage { + String MISSING_API_SPEC = "API specification not provided. Please pass --api-spec or set the API_SPEC environment variable."; + + String MISSING_API_BASE_URL = "API base url not provided. Please pass --api-base-url or set the API_BASE_URL environment variable."; } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaAuthType.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaAuthType.java index e920f647..f322effa 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaAuthType.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaAuthType.java @@ -1,10 +1,52 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ package com.oracle.mcp.openapi.enums; import com.oracle.mcp.openapi.model.McpServerConfig; +/** + * Represents the supported authentication types for the OpenAPI MCP server. + * This enum provides a type-safe way to handle different authentication schemes. + * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ public enum OpenApiSchemaAuthType { - BASIC, BEARER, API_KEY, CUSTOM, NONE; + /** + * Basic Authentication using a username and password. + */ + BASIC, + /** + * Bearer Token Authentication using an opaque token. + */ + BEARER, + /** + * API Key Authentication, where the key can be sent in a header or query parameter. + */ + API_KEY, + /** + * Custom Authentication using a user-defined set of headers. + */ + CUSTOM, + /** + * No authentication is required. + */ + NONE; + /** + * Safely determines the {@code OpenApiSchemaAuthType} from the server configuration. + *

+ * This method parses the raw authentication type string provided in the + * {@link McpServerConfig}. It handles null, empty, or invalid strings by defaulting + * to {@link #NONE}, preventing runtime exceptions. + * + * @param request The server configuration object containing the raw auth type string. + * @return The corresponding {@code OpenApiSchemaAuthType} enum constant. Returns {@link #NONE} + * if the provided auth type is null, empty, or does not match any known type. + */ public static OpenApiSchemaAuthType getType(McpServerConfig request) { String authType = request.getRawAuthType(); if (authType == null || authType.isEmpty()) { diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaSourceType.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaSourceType.java index 7c5d518d..e6f9e6c7 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaSourceType.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaSourceType.java @@ -1,10 +1,42 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ package com.oracle.mcp.openapi.enums; import com.oracle.mcp.openapi.model.McpServerConfig; +/** + * Represents the source type of OpenAPI specification. + *

+ * This enum distinguishes between specifications loaded from a remote URL and + * those read from a local file system path. + * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ public enum OpenApiSchemaSourceType { - URL, FILE; + /** + * The specification is located at a remote URL + */ + URL, + /** + * The specification is located at a path on the local file system. + */ + FILE; + /** + * Determines the source type of the OpenAPI specification from the server configuration. + *

+ * This method inspects the specification location string provided in the + * {@link McpServerConfig}. It returns {@link #URL} if the string starts + * with "http://" or "https://", and {@link #FILE} otherwise. + * + * @param request The server configuration containing the API specification location. + * @return The determined {@code OpenApiSchemaSourceType} (either {@link #URL} or {@link #FILE}). + * @throws IllegalArgumentException if the API specification location is null or empty in the configuration. + */ public static OpenApiSchemaSourceType getType(McpServerConfig request) { // Backward compatibility: prefer specUrl if set String specUrl = request.getApiSpec(); diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaType.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaType.java index a6a4f52c..5266165d 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaType.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaType.java @@ -1,23 +1,55 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ package com.oracle.mcp.openapi.enums; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +/** + * Represents the format of an OpenAPI specification file. + *

+ * This enum is used to identify whether a given specification is written in JSON or YAML, + * or if its format cannot be determined. + * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ public enum OpenApiSchemaType { + /** + * The specification is in JSON format. + */ JSON, + /** + * The specification is in YAML format. + */ YAML, + /** + * The format of the specification could not be determined. + */ UNKNOWN; - // 1. Create reusable, thread-safe mapper instances. This is much more efficient. + /** + * A reusable, thread-safe mapper instance for parsing JSON content. + */ private static final ObjectMapper JSON_MAPPER = new ObjectMapper(); + /** + * A reusable, thread-safe mapper instance for parsing YAML content. + */ private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); /** - * Determines the schema file type from a string by attempting to parse it. + * Determines the schema file type from its string content by attempting to parse it. + *

+ * This method first tries to parse the input string as JSON. If that fails, it + * then attempts to parse it as YAML. If both attempts fail, or if the input is + * null or empty, it returns {@link #UNKNOWN}. * - * @param dataString The string content of the schema file. - * @return The determined OpenApiSchemaFileType (JSON, YAML, or UNKNOWN). + * @param dataString The string content of the OpenAPI specification. + * @return The determined {@code OpenApiSchemaType} ({@link #JSON}, {@link #YAML}, or {@link #UNKNOWN}). */ public static OpenApiSchemaType getType(String dataString) { if (dataString == null || dataString.trim().isEmpty()) { diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/McpServerToolExecutionException.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/McpServerToolExecutionException.java index 56e403e9..b6a6e809 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/McpServerToolExecutionException.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/McpServerToolExecutionException.java @@ -1,12 +1,40 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ package com.oracle.mcp.openapi.exception; -public class McpServerToolExecutionException extends Exception{ - +/** + * A custom exception thrown when an error occurs during the execution of an OpenAPI-based MCP tool. + *

+ * This exception is used to wrap underlying issues such as network errors, I/O problems, + * or any other runtime failure encountered while invoking an external API endpoint. + * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ +public class McpServerToolExecutionException extends Exception { + /** + * Constructs a new {@code McpServerToolExecutionException} with the specified detail message. + * + * @param message The detail message (which is saved for later retrieval by the {@link #getMessage()} method). + */ public McpServerToolExecutionException(String message) { super(message); } + /** + * Constructs a new {@code McpServerToolExecutionException} with the specified detail message and cause. + *

+ * Note that the detail message associated with {@code cause} is not automatically + * incorporated in this exception's detail message. + * + * @param message The detail message (which is saved for later retrieval by the {@link #getMessage()} method). + * @param cause The cause (which is saved for later retrieval by the {@link #getCause()} method). + * A {@code null} value is permitted, and indicates that the cause is nonexistent or unknown. + */ public McpServerToolExecutionException(String message, Throwable cause) { super(message, cause); } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/McpServerToolInitializeException.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/McpServerToolInitializeException.java index f37a0016..849f4e2d 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/McpServerToolInitializeException.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/McpServerToolInitializeException.java @@ -1,12 +1,41 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ package com.oracle.mcp.openapi.exception; -public class McpServerToolInitializeException extends Exception{ - +/** + * A custom exception thrown when an error occurs during the initialization phase of the MCP server. + *

+ * This exception is used to indicate failures related to initial setup, such as parsing + * command-line arguments, reading the OpenAPI specification, or configuring the server + * before it is ready to execute tools. + * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ +public class McpServerToolInitializeException extends Exception { + /** + * Constructs a new {@code McpServerToolInitializeException} with the specified detail message. + * + * @param message The detail message (which is saved for later retrieval by the {@link #getMessage()} method). + */ public McpServerToolInitializeException(String message) { super(message); } + /** + * Constructs a new {@code McpServerToolInitializeException} with the specified detail message and cause. + *

+ * Note that the detail message associated with {@code cause} is not automatically + * incorporated in this exception's detail message. + * + * @param message The detail message (which is saved for later retrieval by the {@link #getMessage()} method). + * @param cause The cause (which is saved for later retrieval by the {@link #getCause()} method). + * A {@code null} value is permitted, and indicates that the cause is nonexistent or unknown. + */ public McpServerToolInitializeException(String message, Throwable cause) { super(message, cause); } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/AuthType.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/AuthType.java deleted file mode 100644 index 151f2939..00000000 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/AuthType.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.oracle.mcp.openapi.fetcher; - -public enum AuthType { - NONE, // No authentication - BASIC, // Basic auth with username/password - BEARER_TOKEN, // OAuth2/JWT bearer token - API_KEY // API key in header or query param -} \ No newline at end of file diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java index 5a475c6b..ada77665 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java @@ -1,3 +1,9 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ package com.oracle.mcp.openapi.fetcher; import com.fasterxml.jackson.databind.JsonNode; @@ -6,7 +12,6 @@ import com.oracle.mcp.openapi.model.McpServerConfig; import com.oracle.mcp.openapi.enums.OpenApiSchemaSourceType; import com.oracle.mcp.openapi.enums.OpenApiSchemaType; -import com.oracle.mcp.openapi.rest.RestApiExecutionService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -23,39 +28,81 @@ import java.util.Arrays; import java.util.Base64; +/** + * Utility class responsible for fetching and parsing OpenAPI schema definitions. + *

+ * A schema can be retrieved either from: + *

    + *
  • A remote URL (with optional Basic Authentication)
  • + *
  • A local file system path
  • + *
+ *

+ * Once retrieved, the schema content is parsed into a Jackson {@link JsonNode} + * using either a JSON or YAML {@link ObjectMapper}, depending on the detected format. + * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ public class OpenApiSchemaFetcher { private static final Logger LOGGER = LoggerFactory.getLogger(OpenApiSchemaFetcher.class); - - private final ObjectMapper jsonMapper ; + private final ObjectMapper jsonMapper; private final ObjectMapper yamlMapper; - private final RestApiExecutionService restApiExecutionService; - public OpenApiSchemaFetcher(RestApiExecutionService restApiExecutionService, ObjectMapper jsonMapper, ObjectMapper yamlMapper){ - this.restApiExecutionService = restApiExecutionService; + /** + * Creates a new {@code OpenApiSchemaFetcher}. + * + * @param jsonMapper {@link ObjectMapper} configured for JSON parsing. + * @param yamlMapper {@link ObjectMapper} configured for YAML parsing. + */ + public OpenApiSchemaFetcher(ObjectMapper jsonMapper, ObjectMapper yamlMapper) { this.jsonMapper = jsonMapper; this.yamlMapper = yamlMapper; } + /** + * Fetches an OpenAPI schema from the configured source. + *

+ * The schema may be downloaded from a remote URL or read from a file, + * depending on the {@link OpenApiSchemaSourceType}. + * + * @param mcpServerConfig configuration containing schema location and authentication details. + * @return the parsed OpenAPI schema as a {@link JsonNode}. + * @throws Exception if the schema cannot be retrieved or parsed. + */ public JsonNode fetch(McpServerConfig mcpServerConfig) throws Exception { OpenApiSchemaSourceType type = OpenApiSchemaSourceType.getType(mcpServerConfig); - String content = null; - if(type == OpenApiSchemaSourceType.URL){ + String content; + if (type == OpenApiSchemaSourceType.URL) { content = downloadContent(mcpServerConfig); - }else{ + } else { content = loadFromFile(mcpServerConfig); } - return parseContent(content); } + /** + * Reads the OpenAPI specification from a local file. + * + * @param mcpServerConfig configuration specifying the file path. + * @return schema content as a UTF-8 string. + * @throws IOException if the file cannot be read. + */ private String loadFromFile(McpServerConfig mcpServerConfig) throws IOException { Path path = Paths.get(mcpServerConfig.getApiSpec()); return Files.readString(path, StandardCharsets.UTF_8); } - + /** + * Downloads the OpenAPI specification from a remote URL. + *

+ * If Basic Authentication is configured, the request will include the + * {@code Authorization} header. + * + * @param mcpServerConfig configuration specifying the URL and authentication details. + * @return schema content as a UTF-8 string. + * @throws Exception if the download fails. + */ private String downloadContent(McpServerConfig mcpServerConfig) throws Exception { URL url = new URL(mcpServerConfig.getApiSpec()); HttpURLConnection conn = (HttpURLConnection) url.openConnection(); @@ -71,6 +118,16 @@ private String downloadContent(McpServerConfig mcpServerConfig) throws Exception } } + /** + * Applies authentication headers to the connection if required. + *

+ * Currently supports only {@link OpenApiSchemaAuthType#BASIC}. + * Passwords and sensitive data are securely cleared from memory + * after use. + * + * @param conn the {@link HttpURLConnection} to update. + * @param mcpServerConfig configuration containing authentication details. + */ private void applyAuth(HttpURLConnection conn, McpServerConfig mcpServerConfig) { OpenApiSchemaAuthType authType = mcpServerConfig.getAuthType(); if (authType != OpenApiSchemaAuthType.BASIC) { @@ -85,33 +142,26 @@ private void applyAuth(HttpURLConnection conn, McpServerConfig mcpServerConfig) return; } - // This will hold the "username:password" bytes byte[] credentialsBytes = null; - // This will hold a temporary copy of the password as bytes byte[] passwordBytes = null; try { - // 2. Convert username to bytes. byte[] usernameBytes = username.getBytes(StandardCharsets.UTF_8); byte[] separator = {':'}; - // Convert password char[] to byte[] for encoding. ByteBuffer passwordByteBuffer = StandardCharsets.UTF_8.encode(CharBuffer.wrap(passwordChars)); passwordBytes = new byte[passwordByteBuffer.remaining()]; passwordByteBuffer.get(passwordBytes); - // 3. Combine them all in a byte array, avoiding intermediate Strings. credentialsBytes = new byte[usernameBytes.length + separator.length + passwordBytes.length]; System.arraycopy(usernameBytes, 0, credentialsBytes, 0, usernameBytes.length); System.arraycopy(separator, 0, credentialsBytes, usernameBytes.length, separator.length); System.arraycopy(passwordBytes, 0, credentialsBytes, usernameBytes.length + separator.length, passwordBytes.length); - // 4. Base64 encode the combined bytes and set the header. String encoded = Base64.getEncoder().encodeToString(credentialsBytes); conn.setRequestProperty("Authorization", "Basic " + encoded); } finally { - // 5. IMPORTANT: Clear all sensitive arrays from memory. Arrays.fill(passwordChars, '0'); if (passwordBytes != null) { Arrays.fill(passwordBytes, (byte) 0); @@ -122,7 +172,15 @@ private void applyAuth(HttpURLConnection conn, McpServerConfig mcpServerConfig) } } - + /** + * Parses schema content into a {@link JsonNode}. + *

+ * Automatically detects whether the input is YAML or JSON. + * + * @param content schema content as a string. + * @return parsed {@link JsonNode}. + * @throws Exception if parsing fails. + */ private JsonNode parseContent(String content) throws Exception { OpenApiSchemaType type = OpenApiSchemaType.getType(content); if (type == OpenApiSchemaType.YAML) { @@ -131,5 +189,4 @@ private JsonNode parseContent(String content) throws Exception { return jsonMapper.readTree(content); } } - } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/McpToolMapper.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/McpToolMapper.java index 0fd464fc..f59b4a65 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/McpToolMapper.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/McpToolMapper.java @@ -1,3 +1,9 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ package com.oracle.mcp.openapi.mapper; import com.fasterxml.jackson.databind.JsonNode; @@ -5,7 +11,25 @@ import java.util.List; +/** + * Mapper interface for converting OpenAPI specifications into + * {@link McpSchema.Tool} representations. + *

+ * Implementations of this interface are responsible for reading a parsed + * API specification (as a Jackson {@link JsonNode}) and mapping it into + * a list of tool definitions that conform to the MCP schema. + * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ public interface McpToolMapper { + /** + * Converts an OpenAPI specification into a list of MCP tools. + * + * @param apiSpec the OpenAPI specification represented as a {@link JsonNode}; + * must not be {@code null}. + * @return a list of {@link McpSchema.Tool} objects derived from the given API specification; + * never {@code null}, but may be empty if no tools are found. + */ List convert(JsonNode apiSpec); } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapper.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapper.java index 56b301e6..c90aec50 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapper.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapper.java @@ -1,3 +1,9 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ package com.oracle.mcp.openapi.mapper.impl; import com.fasterxml.jackson.databind.JsonNode; @@ -18,6 +24,17 @@ import java.util.*; +/** + * Implementation of {@link McpToolMapper} that converts an OpenAPI specification + * into a list of {@link McpSchema.Tool} objects. + *

+ * This class parses the OpenAPI JSON, extracts operations, parameters, + * request/response schemas, and builds tool definitions compatible + * with the MCP schema. Converted tools are also cached using + * {@link McpServerCacheService}. + * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ public class OpenApiToMcpToolMapper implements McpToolMapper { private final Set usedNames = new HashSet<>(); @@ -80,7 +97,7 @@ private McpSchema.Tool buildToolFromOperation(String path, PathItem.HttpMethod m String toolTitle = (operation.getSummary() != null && !operation.getSummary().isEmpty()) ? operation.getSummary() : toolName; - String toolDescription = getDescription(toolName, method.name(), path, operation); + String toolDescription = getDescription(operation); Map properties = new LinkedHashMap<>(); List requiredParams = new ArrayList<>(); @@ -134,7 +151,6 @@ private Map buildMeta(PathItem.HttpMethod method, String path, return meta; } - // Modified helper method to populate all relevant collections private void extractPathAndQueryParams(Operation operation, Map> pathParams, Map> queryParams, @@ -144,7 +160,6 @@ private void extractPathAndQueryParams(Operation operation, for (Parameter param : operation.getParameters()) { if (param.getName() == null || param.getSchema() == null) continue; - // This part is for your meta block (original behavior) Map paramMeta = parameterMetaMap(param); if ("path".equalsIgnoreCase(param.getIn())) { pathParams.put(param.getName(), paramMeta); @@ -152,26 +167,12 @@ private void extractPathAndQueryParams(Operation operation, queryParams.put(param.getName(), paramMeta); } - // This part is for your inputSchema if ("path".equalsIgnoreCase(param.getIn()) || "query".equalsIgnoreCase(param.getIn())) { - - // --- MODIFICATION START --- - - // 1. Get the base schema for the parameter Map paramSchema = extractInputSchema(param.getSchema()); - - // 2. Manually add the description from the Parameter object itself if (param.getDescription() != null && !param.getDescription().isEmpty()) { paramSchema.put("description", param.getDescription()); } - - // 3. Add the complete schema (with description) to the properties map properties.put(param.getName(), paramSchema); - - // --- MODIFICATION END --- - - - // If required, add it to the list of required parameters if (Boolean.TRUE.equals(param.getRequired())) { requiredParams.add(param.getName()); } @@ -186,53 +187,13 @@ private void updateToolsToCache(List tools) { } } - public String getDescription(String toolName, String httpMethod, String path, Operation operation) { - StringBuilder doc = new StringBuilder(); - + private String getDescription(Operation operation) { if (operation.getSummary() != null && !operation.getSummary().isEmpty()) { - doc.append(operation.getSummary()).append("\n"); + return operation.getSummary(); } else if (operation.getDescription() != null && !operation.getDescription().isEmpty()) { - doc.append(operation.getDescription()).append("\n"); - } -// -// doc.append("HTTP Method : ").append(httpMethod.toUpperCase()).append("\n"); -// doc.append("End URL : ").append(" `").append(path).append("`\n"); -// -// if (operation.getParameters() != null && !operation.getParameters().isEmpty()) { -// appendParameterList(doc, "Path parameters: ", operation.getParameters(), "path"); -// appendParameterList(doc, "Query parameters: ", operation.getParameters(), "query"); -// } -// -// if (operation.getRequestBody() != null) { -// doc.append("Request body: ") -// .append(Boolean.TRUE.equals(operation.getRequestBody().getRequired()) ? "Required" : "Optional") -// .append("\n"); -// } -// -// appendExampleUsage(doc, toolName); - return doc.toString(); - } - - private void appendParameterList(StringBuilder doc, String label, List parameters, String type) { - doc.append(label); - for (Parameter p : parameters) { - if (type.equals(p.getIn())) { - doc.append(p.getName()) - .append(Boolean.TRUE.equals(p.getRequired()) ? "*" : "") - .append(","); - } + return operation.getDescription(); } - doc.append("\n"); - } - - private void appendExampleUsage(StringBuilder doc, String toolName) { - doc.append("Example usage: "); - doc.append("```json\n"); - doc.append("{\n"); - doc.append(" \"tool_name\": \"").append(toolName).append("\",\n"); - doc.append(" \"arguments\": {}\n"); - doc.append("}\n"); - doc.append("```\n"); + return ""; } private OpenAPI parseOpenApi(JsonNode jsonNode) { @@ -243,32 +204,14 @@ private OpenAPI parseOpenApi(JsonNode jsonNode) { return new OpenAPIV3Parser().readContents(jsonString, null, options).getOpenAPI(); } - public McpSchema.JsonSchema buildInputSchemaFromOperation(Operation operation) { - Map properties = new LinkedHashMap<>(); - List requiredParams = new ArrayList<>(); - - extractRequestBody(operation, properties, requiredParams); - - // This now assumes McpSchema.JsonSchema is defined elsewhere in your project - return new McpSchema.JsonSchema( - "object", - properties.isEmpty() ? null : properties, - requiredParams.isEmpty() ? null : requiredParams, - false, null, null - ); - } - private void extractRequestBody(Operation operation, Map properties, List requiredParams) { - // Extract from request body if (operation.getRequestBody() != null && operation.getRequestBody().getContent() != null) { - // Assuming a single JSON media type for simplicity MediaType media = operation.getRequestBody().getContent().get("application/json"); if (media != null && media.getSchema() != null) { Schema bodySchema = media.getSchema(); - if ("object".equals(bodySchema.getType()) && bodySchema.getProperties() != null) { bodySchema.getProperties().forEach((name, schema) -> { - properties.put(name.toString(), extractInputSchema((Schema) schema)); + properties.put(name, extractInputSchema((Schema) schema)); }); if (bodySchema.getRequired() != null) { requiredParams.addAll(bodySchema.getRequired()); @@ -285,23 +228,15 @@ private Map extractInputSchema(Schema openApiSchema) { Map jsonSchema = new LinkedHashMap<>(); - // Copy basic properties if (openApiSchema.getType() != null) jsonSchema.put("type", openApiSchema.getType()); - if (openApiSchema.getDescription() != null) { - jsonSchema.put("description", openApiSchema.getDescription()); - } + if (openApiSchema.getDescription() != null) jsonSchema.put("description", openApiSchema.getDescription()); if (openApiSchema.getFormat() != null) jsonSchema.put("format", openApiSchema.getFormat()); if (openApiSchema.getEnum() != null) jsonSchema.put("enum", openApiSchema.getEnum()); - // --- Recursive Handling --- - - // If it's an object, process its properties recursively if ("object".equals(openApiSchema.getType())) { if (openApiSchema.getProperties() != null) { Map nestedProperties = new LinkedHashMap<>(); - openApiSchema.getProperties().forEach((key, value) -> { - nestedProperties.put(key.toString(), extractInputSchema((Schema) value)); - }); + openApiSchema.getProperties().forEach((key, value) -> nestedProperties.put(key, extractInputSchema((Schema) value))); jsonSchema.put("properties", nestedProperties); } if (openApiSchema.getRequired() != null) { @@ -309,67 +244,74 @@ private Map extractInputSchema(Schema openApiSchema) { } } - // If it's an array, process its 'items' schema recursively if ("array".equals(openApiSchema.getType())) { if (openApiSchema.getItems() != null) { jsonSchema.put("items", extractInputSchema(openApiSchema.getItems())); } } - return jsonSchema; } + private Map extractOutputSchema(Operation operation) { + // This is the parent object that the tool schema requires. + // It will ALWAYS have "type": "object". + Map toolOutputSchema = new LinkedHashMap<>(); + toolOutputSchema.put("type", "object"); - public Map extractOutputSchema(Operation operation) { + // Find the actual response schema from the OpenAPI spec if (operation.getResponses() == null || operation.getResponses().isEmpty()) { - return new HashMap<>(); - } - - ApiResponse response = operation.getResponses().get("200"); - if (response == null) { - response = operation.getResponses().get("default"); + return toolOutputSchema; // Return empty object schema if no responses } + ApiResponse response = operation.getResponses().getOrDefault("200", operation.getResponses().get("default")); if (response == null || response.getContent() == null || !response.getContent().containsKey("application/json")) { - return new HashMap<>(); + return toolOutputSchema; // Return empty object schema } - - Schema schema = response.getContent().get("application/json").getSchema(); - if (schema == null) { - return new HashMap<>(); + Schema apiResponseSchema = response.getContent().get("application/json").getSchema(); + if (apiResponseSchema == null) { + return toolOutputSchema; // Return empty object schema } - return extractOutputSchema(schema); + + // Recursively convert the OpenAPI response schema into a JSON schema map + Map convertedApiResponseSchema = extractSchemaRecursive(apiResponseSchema); + + // Create the 'properties' map for our parent object + Map properties = new LinkedHashMap<>(); + + // Put the actual API response schema inside the properties map. + // We'll use the key "response" to hold it. + properties.put("response", convertedApiResponseSchema); + + toolOutputSchema.put("properties", properties); + + return toolOutputSchema; } - public Map extractOutputSchema(Schema schema) { + private Map extractSchemaRecursive(Schema schema) { if (schema == null) { return Collections.emptyMap(); } Map jsonSchema = new LinkedHashMap<>(); - // Copy basic properties - if (schema.getType() != null) { - jsonSchema.put("type", new String[]{schema.getType(), "null"}); - } - if (schema.getDescription() != null) { - jsonSchema.put("description", schema.getDescription()); - } + if (schema.getType() != null) jsonSchema.put("type", schema.getType()); + if (schema.getDescription() != null) jsonSchema.put("description", schema.getDescription()); - // If it's an object, recursively process its properties if ("object".equals(schema.getType()) && schema.getProperties() != null) { Map properties = new LinkedHashMap<>(); for (Map.Entry entry : schema.getProperties().entrySet()) { - properties.put(entry.getKey(), extractOutputSchema(entry.getValue())); + properties.put(entry.getKey(), extractSchemaRecursive(entry.getValue())); } jsonSchema.put("properties", properties); } - // Add other properties you may need, like "required", "items" for arrays, etc. + if ("array".equals(schema.getType()) && schema.getItems() != null) { + jsonSchema.put("items", extractSchemaRecursive(schema.getItems())); + } + if (schema.getRequired() != null) { jsonSchema.put("required", schema.getRequired()); } - return jsonSchema; } @@ -410,4 +352,4 @@ private static String toCamelCase(String input) { } return sb.toString(); } -} +} \ No newline at end of file diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java index f39584f4..b4f4827a 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java @@ -1,3 +1,9 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ package com.oracle.mcp.openapi.mapper.impl; import com.fasterxml.jackson.databind.JsonNode; @@ -19,33 +25,65 @@ import java.util.*; +/** + * Implementation of {@link McpToolMapper} that converts Swagger 2.0 specifications + * into MCP-compliant tool definitions. + *

+ * This mapper reads a Swagger JSON/YAML specification, extracts paths, operations, + * parameters, and models, and builds a list of {@link McpSchema.Tool} objects. + *

+ * The generated tools are also cached via {@link McpServerCacheService} for reuse. + * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ public class SwaggerToMcpToolMapper implements McpToolMapper { private final Set usedNames = new HashSet<>(); private final McpServerCacheService mcpServerCacheService; private static final Logger LOGGER = LoggerFactory.getLogger(SwaggerToMcpToolMapper.class); + private Swagger swaggerSpec; // Stores the full spec to resolve $ref definitions + /** + * Creates a new {@code SwaggerToMcpToolMapper}. + * + * @param mcpServerCacheService cache service used to store generated MCP tools. + */ public SwaggerToMcpToolMapper(McpServerCacheService mcpServerCacheService) { this.mcpServerCacheService = mcpServerCacheService; } + /** + * Converts a Swagger 2.0 specification (as a Jackson {@link JsonNode}) + * into a list of MCP tools. + * + * @param swaggerJson the Swagger specification in JSON tree form. + * @return a list of {@link McpSchema.Tool} objects. + * @throws IllegalArgumentException if the specification does not contain a {@code paths} object. + */ @Override public List convert(JsonNode swaggerJson) { LOGGER.debug("Parsing Swagger 2 JsonNode to Swagger object..."); - Swagger swagger = parseSwagger(swaggerJson); + this.swaggerSpec = parseSwagger(swaggerJson); - if (swagger.getPaths() == null || swagger.getPaths().isEmpty()) { + if (swaggerSpec.getPaths() == null || swaggerSpec.getPaths().isEmpty()) { throw new IllegalArgumentException("'paths' object not found in the specification."); } - List mcpTools = processPaths(swagger); + List mcpTools = processPaths(swaggerSpec); LOGGER.debug("Conversion complete. Total tools created: {}", mcpTools.size()); updateToolsToCache(mcpTools); return mcpTools; } + /** + * Processes all paths in the Swagger specification and builds corresponding MCP tools. + * + * @param swagger the parsed Swagger object. + * @return list of {@link McpSchema.Tool}. + */ private List processPaths(Swagger swagger) { List mcpTools = new ArrayList<>(); + if (swagger.getPaths() == null) return mcpTools; for (Map.Entry pathEntry : swagger.getPaths().entrySet()) { String path = pathEntry.getKey(); @@ -57,22 +95,33 @@ private List processPaths(Swagger swagger) { return mcpTools; } + /** + * Extracts operations (GET, POST, etc.) for a given Swagger path and converts them to MCP tools. + * + * @param path the API path (e.g., {@code /users}). + * @param pathItem the Swagger path item containing operations. + * @param mcpTools the list to which new tools will be added. + */ private void processOperationsForPath(String path, Path pathItem, List mcpTools) { Map operations = pathItem.getOperationMap(); if (operations == null) return; for (Map.Entry methodEntry : operations.entrySet()) { - HttpMethod method = methodEntry.getKey(); - Operation operation = methodEntry.getValue(); - if (operation == null) continue; - - McpSchema.Tool tool = buildToolFromOperation(path, method, operation); + McpSchema.Tool tool = buildToolFromOperation(path, methodEntry.getKey(), methodEntry.getValue()); if (tool != null) { mcpTools.add(tool); } } } + /** + * Builds an MCP tool definition from a Swagger operation. + * + * @param path the API path. + * @param method the HTTP method. + * @param operation the Swagger operation metadata. + * @return a constructed {@link McpSchema.Tool}, or {@code null} if the operation is invalid. + */ private McpSchema.Tool buildToolFromOperation(String path, HttpMethod method, Operation operation) { String rawOperationId = (operation.getOperationId() != null && !operation.getOperationId().isEmpty()) ? operation.getOperationId() @@ -81,10 +130,8 @@ private McpSchema.Tool buildToolFromOperation(String path, HttpMethod method, Op String toolName = makeUniqueName(rawOperationId); LOGGER.debug("--- Parsing Operation: {} {} (ID: {}) ---", method.name(), path, toolName); - String toolTitle = (operation.getSummary() != null && !operation.getSummary().isEmpty()) - ? operation.getSummary() - : toolName; - String toolDescription = getDescription(toolName, method.name(), path, operation); + String toolTitle = operation.getSummary() != null ? operation.getSummary() : toolName; + String toolDescription = operation.getDescription() != null ? operation.getDescription() : toolTitle; Map properties = new LinkedHashMap<>(); List requiredParams = new ArrayList<>(); @@ -92,7 +139,6 @@ private McpSchema.Tool buildToolFromOperation(String path, HttpMethod method, Op Map> pathParams = new HashMap<>(); Map> queryParams = new HashMap<>(); extractPathAndQueryParams(operation, pathParams, queryParams, properties, requiredParams); - extractRequestBody(operation, properties, requiredParams); McpSchema.JsonSchema inputSchema = new McpSchema.JsonSchema( @@ -101,9 +147,8 @@ private McpSchema.Tool buildToolFromOperation(String path, HttpMethod method, Op requiredParams.isEmpty() ? null : requiredParams, false, null, null ); - Map outputSchema = extractOutputSchema(operation); - outputSchema.put("additionalProperties", true); + Map outputSchema = extractOutputSchema(operation); Map meta = buildMeta(method, path, operation, pathParams, queryParams); return McpSchema.Tool.builder() @@ -116,56 +161,30 @@ private McpSchema.Tool buildToolFromOperation(String path, HttpMethod method, Op .build(); } - private Map buildMeta(HttpMethod method, String path, - Operation operation, Map> pathParams, - Map> queryParams) { - Map meta = new LinkedHashMap<>(); - meta.put("httpMethod", method.name()); - meta.put("path", path); - if (operation.getTags() != null) { - meta.put("tags", operation.getTags()); - } - if (operation.getSecurity() != null) { - meta.put("security", operation.getSecurity()); - } - if (!pathParams.isEmpty()) { - meta.put("pathParams", pathParams); - } - if (!queryParams.isEmpty()) { - meta.put("queryParams", queryParams); - } - return meta; - } - + /** + * Extracts path and query parameters from a Swagger operation and adds them to the input schema. + */ private void extractPathAndQueryParams(Operation operation, Map> pathParams, Map> queryParams, Map properties, List requiredParams) { - if (operation.getParameters() != null) { - for (Parameter param : operation.getParameters()) { - if (param.getName() == null) continue; - - Map paramMeta = new LinkedHashMap<>(); - paramMeta.put("name", param.getName()); - paramMeta.put("required", param.getRequired()); - if (param.getDescription() != null) { - paramMeta.put("description", param.getDescription()); - } + if (operation.getParameters() == null) return; + + for (Parameter param : operation.getParameters()) { + if (param instanceof PathParameter || param instanceof QueryParameter) { + Map paramSchema = new LinkedHashMap<>(); + paramSchema.put("description", param.getDescription()); if (param instanceof PathParameter) { - pathParams.put(param.getName(), paramMeta); - } else if (param instanceof QueryParameter) { - queryParams.put(param.getName(), paramMeta); + paramSchema.put("type", ((PathParameter) param).getType()); + pathParams.put(param.getName(), Map.of("name", param.getName(), "required", param.getRequired())); + } else { + paramSchema.put("type", ((QueryParameter) param).getType()); + queryParams.put(param.getName(), Map.of("name", param.getName(), "required", param.getRequired())); } - Map paramSchema = new LinkedHashMap<>(); - if (param.getDescription() != null) { - paramSchema.put("description", param.getDescription()); - } - paramSchema.put("type", "string"); // fallback for Swagger 2 simple params properties.put(param.getName(), paramSchema); - if (param.getRequired()) { requiredParams.add(param.getName()); } @@ -173,100 +192,144 @@ private void extractPathAndQueryParams(Operation operation, } } + /** + * Extracts request body schema (if present) from a Swagger operation. + */ private void extractRequestBody(Operation operation, Map properties, List requiredParams) { - if (operation.getParameters() != null) { - for (Parameter param : operation.getParameters()) { - if (param instanceof BodyParameter) { - Model schema = ((BodyParameter) param).getSchema(); - if (schema instanceof ModelImpl) { - ModelImpl impl = (ModelImpl) schema; - if (impl.getProperties() != null) { - for (Map.Entry entry : impl.getProperties().entrySet()) { - properties.put(entry.getKey(), extractPropertySchema(entry.getValue())); - } - } - if (impl.getRequired() != null) { - requiredParams.addAll(impl.getRequired()); + if (operation.getParameters() == null) return; + + operation.getParameters().stream() + .filter(p -> p instanceof BodyParameter) + .findFirst() + .ifPresent(p -> { + BodyParameter bodyParam = (BodyParameter) p; + Model schema = bodyParam.getSchema(); + Map bodyProps = extractModelSchema(schema); + + if (!bodyProps.isEmpty()) { + properties.put(bodyParam.getName(), bodyProps); + if (bodyParam.getRequired()) { + requiredParams.add(bodyParam.getName()); } } - } + }); + } + + /** + * Recursively extracts schema details from a Swagger model definition. + */ + private Map extractModelSchema(Model model) { + if (model instanceof RefModel) { + String ref = ((RefModel) model).getSimpleRef(); + model = swaggerSpec.getDefinitions().get(ref); + } + + Map schema = new LinkedHashMap<>(); + if (model instanceof ModelImpl && ((ModelImpl) model).getProperties() != null) { + Map props = new LinkedHashMap<>(); + ((ModelImpl) model).getProperties().forEach((key, prop) -> { + props.put(key, extractPropertySchema(prop)); + }); + schema.put("type", "object"); + schema.put("properties", props); + if (((ModelImpl) model).getRequired() != null) { + schema.put("required", ((ModelImpl) model).getRequired()); } + } else if (model instanceof ArrayModel) { + schema.put("type", "array"); + schema.put("items", extractPropertySchema(((ArrayModel) model).getItems())); } + return schema; } + /** + * Extracts schema information from a Swagger property. + */ private Map extractPropertySchema(Property property) { - Map schema = new LinkedHashMap<>(); - if (property instanceof RefProperty) { - // Replace $ref with empty object - schema.put("type", "object"); - schema.put("properties", new LinkedHashMap<>()); - schema.put("additionalProperties", true); - return schema; + String simpleRef = ((RefProperty) property).getSimpleRef(); + Model definition = swaggerSpec.getDefinitions().get(simpleRef); + if (definition != null) { + return extractModelSchema(definition); + } else { + return Map.of("type", "object", "description", "Unresolved reference: " + simpleRef); + } } - if (property.getType() != null) schema.put("type", property.getType()); - if (property.getDescription() != null) schema.put("description", property.getDescription()); + Map schema = new LinkedHashMap<>(); + schema.put("type", property.getType()); + schema.put("description", property.getDescription()); if (property instanceof ObjectProperty) { - Map nestedProps = new LinkedHashMap<>(); - ObjectProperty objProp = (ObjectProperty) property; - if (objProp.getProperties() != null) { - for (Map.Entry entry : objProp.getProperties().entrySet()) { - nestedProps.put(entry.getKey(), extractPropertySchema(entry.getValue())); - } - } - schema.put("properties", nestedProps); - schema.put("additionalProperties", true); - } - - if (property instanceof ArrayProperty) { - ArrayProperty arrProp = (ArrayProperty) property; - schema.put("type", "array"); - schema.put("items", extractPropertySchema(arrProp.getItems())); + Map props = new LinkedHashMap<>(); + ((ObjectProperty) property).getProperties().forEach((key, prop) -> { + props.put(key, extractPropertySchema(prop)); + }); + schema.put("properties", props); + } else if (property instanceof ArrayProperty) { + schema.put("items", extractPropertySchema(((ArrayProperty) property).getItems())); } - return schema; } + /** + * Extracts the output schema for an operation (based on its 200/default response). + */ + private Map extractOutputSchema(Operation operation) { + Map toolOutputSchema = new LinkedHashMap<>(); + toolOutputSchema.put("type", "object"); - public Map extractOutputSchema(Operation operation) { if (operation.getResponses() == null || operation.getResponses().isEmpty()) { - return new HashMap<>(); + return toolOutputSchema; } - Response response = operation.getResponses().get("200"); - if (response == null) { - response = operation.getResponses().get("default"); - } + Response response = operation.getResponses().getOrDefault("200", operation.getResponses().get("default")); if (response == null || response.getSchema() == null) { - return new HashMap<>(); + return toolOutputSchema; } - return extractPropertySchema(response.getSchema()); + Map apiResponseSchema = extractPropertySchema(response.getSchema()); + Map properties = new LinkedHashMap<>(); + properties.put("response", apiResponseSchema); + toolOutputSchema.put("properties", properties); + + return toolOutputSchema; } + /** + * Builds metadata for an MCP tool, including HTTP method, path, tags, security, and parameter maps. + */ + private Map buildMeta(HttpMethod method, String path, Operation operation, + Map> pathParams, Map> queryParams) { + Map meta = new LinkedHashMap<>(); + meta.put("httpMethod", method.name()); + meta.put("path", path); + if (operation.getTags() != null) meta.put("tags", operation.getTags()); + if (operation.getSecurity() != null) meta.put("security", operation.getSecurity()); + if (!pathParams.isEmpty()) meta.put("pathParams", pathParams); + if (!queryParams.isEmpty()) meta.put("queryParams", queryParams); + return meta; + } + + /** + * Stores the generated tools into the cache service. + */ private void updateToolsToCache(List tools) { for (McpSchema.Tool tool : tools) { mcpServerCacheService.putTool(tool.name(), tool); } } - public String getDescription(String toolName, String httpMethod, String path, Operation operation) { - StringBuilder doc = new StringBuilder(); - if (operation.getSummary() != null && !operation.getSummary().isEmpty()) { - doc.append(operation.getSummary()).append("\n"); - } else if (operation.getDescription() != null && !operation.getDescription().isEmpty()) { - doc.append(operation.getDescription()).append("\n"); - } - return doc.toString(); - } - + /** + * Parses the Swagger JSON into a {@link Swagger} object. + */ private Swagger parseSwagger(JsonNode jsonNode) { - String jsonString = jsonNode.toString(); - return new SwaggerParser().parse(jsonString); + return new SwaggerParser().parse(jsonNode.toString()); } + /** + * Ensures a unique tool name by appending a numeric suffix if necessary. + */ private String makeUniqueName(String base) { String name = base; int counter = 1; @@ -277,12 +340,16 @@ private String makeUniqueName(String base) { return name; } + /** + * Converts an input string into camelCase format. + */ private static String toCamelCase(String input) { + if (input == null || input.isEmpty()) return ""; String[] parts = input.split("[^a-zA-Z0-9]+"); StringBuilder sb = new StringBuilder(); for (int i = 0; i < parts.length; i++) { - if (parts[i].isEmpty()) continue; String word = parts[i].toLowerCase(); + if (word.isEmpty()) continue; if (i == 0) { sb.append(word); } else { diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/McpServerConfig.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/McpServerConfig.java index ae48ef04..289e0f6a 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/McpServerConfig.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/McpServerConfig.java @@ -1,9 +1,19 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ package com.oracle.mcp.openapi.model; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; import com.oracle.mcp.openapi.constants.ErrorMessage; import com.oracle.mcp.openapi.enums.OpenApiSchemaAuthType; import com.oracle.mcp.openapi.exception.McpServerToolInitializeException; +import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -13,9 +23,13 @@ * Secrets (token, password, api-key) are stored in char arrays * and should be cleared by the consumer after use. * Environment variables override CLI arguments if both are present. + * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) */ public final class McpServerConfig { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + // Specification source private final String apiName; private final String apiBaseUrl; @@ -29,6 +43,7 @@ public final class McpServerConfig { private final char[] authApiKey; private final String authApiKeyName; private final String authApiKeyIn; + private final Map authCustomHeaders; // Network configs private final String connectTimeout; @@ -38,48 +53,75 @@ public final class McpServerConfig { private final String proxyHost; private final Integer proxyPort; - private McpServerConfig(String apiName, String apiBaseUrl, - String authType, char[] authToken, String authUsername, - char[] authPassword, char[] authApiKey, String authApiKeyName, - String authApiKeyIn, String apiSpec, - String connectTimeout, String responseTimeout, - String httpVersion, String redirectPolicy, - String proxyHost, Integer proxyPort) { - this.apiName = apiName; - this.apiBaseUrl = apiBaseUrl; - this.authType = authType; - this.authToken = authToken != null ? authToken.clone() : null; - this.authUsername = authUsername; - this.authPassword = authPassword != null ? authPassword.clone() : null; - this.authApiKey = authApiKey != null ? authApiKey.clone() : null; - this.authApiKeyName = authApiKeyName; - this.authApiKeyIn = authApiKeyIn; - this.apiSpec = apiSpec; - this.connectTimeout = connectTimeout; - this.responseTimeout = responseTimeout; - this.httpVersion = httpVersion; - this.redirectPolicy = redirectPolicy; - this.proxyHost = proxyHost; - this.proxyPort = proxyPort; + public McpServerConfig(Builder builder) { + this.apiName = builder.apiName; + this.apiBaseUrl = builder.apiBaseUrl; + this.apiSpec = builder.apiSpec; + this.authType = builder.authType; + this.authToken = builder.authToken != null ? builder.authToken.clone() : null; + this.authUsername = builder.authUsername; + this.authPassword = builder.authPassword != null ? builder.authPassword.clone() : null; + this.authApiKey = builder.authApiKey != null ? builder.authApiKey.clone() : null; + this.authApiKeyName = builder.authApiKeyName; + this.authApiKeyIn = builder.authApiKeyIn; + this.authCustomHeaders = builder.authCustomHeaders != null ? Map.copyOf(builder.authCustomHeaders) : Collections.emptyMap(); + this.connectTimeout = builder.connectTimeout; + this.responseTimeout = builder.responseTimeout; + this.httpVersion = builder.httpVersion; + this.redirectPolicy = builder.redirectPolicy; + this.proxyHost = builder.proxyHost; + this.proxyPort = builder.proxyPort; } // ----------------- GETTERS ----------------- - public String getApiName() { return apiName; } - public String getApiBaseUrl() { return apiBaseUrl; } - public String getRawAuthType() { return authType; } - public OpenApiSchemaAuthType getAuthType() { return OpenApiSchemaAuthType.getType(this); } - public String getAuthUsername() { return authUsername; } - public String getAuthToken() { return authToken != null ? new String(authToken) : null; } + public String getApiName() { + return apiName; + } + + public String getApiBaseUrl() { + return apiBaseUrl; + } + + public String getRawAuthType() { + return authType; + } + + public OpenApiSchemaAuthType getAuthType() { + return OpenApiSchemaAuthType.getType(this); + } + + public String getAuthUsername() { + return authUsername; + } + + public char[] getAuthToken() { + return authToken != null ? authToken.clone() : null; + } public char[] getAuthPassword() { - return authPassword; + return authPassword != null ? authPassword.clone() : null; + } + + public char[] getAuthApiKey() { + return authApiKey != null ? authApiKey.clone() : null; + } + + public String getAuthApiKeyName() { + return authApiKeyName; + } + + public String getAuthApiKeyIn() { + return authApiKeyIn; + } + + public Map getAuthCustomHeaders() { + return authCustomHeaders; + } + + public String getApiSpec() { + return apiSpec; } - public char[] getAuthApiKey() { return authApiKey != null ? authApiKey.clone() : null; } - public String getAuthApiKeyName() { return authApiKeyName; } - public String getAuthApiKeyIn() { return authApiKeyIn; } - public String getApiSpec() { return apiSpec; } - // Timeouts as long public long getConnectTimeoutMs() { try { return Long.parseLong(connectTimeout); @@ -98,57 +140,237 @@ public long getResponseTimeoutMs() { } } - // Original string getters - public String getConnectTimeout() { return connectTimeout; } - public String getResponseTimeout() { return responseTimeout; } + public String getConnectTimeout() { + return connectTimeout; + } + + public String getResponseTimeout() { + return responseTimeout; + } + + public String getHttpVersion() { + return httpVersion; + } + + public String getRedirectPolicy() { + return redirectPolicy; + } + + public String getProxyHost() { + return proxyHost; + } + + public Integer getProxyPort() { + return proxyPort; + } - public String getHttpVersion() { return httpVersion; } - public String getRedirectPolicy() { return redirectPolicy; } - public String getProxyHost() { return proxyHost; } - public Integer getProxyPort() { return proxyPort; } + // ----------------- BUILDER ----------------- + public static class Builder { + private String apiName; + private String apiBaseUrl; + private String apiSpec; + private String authType; + private char[] authToken; + private String authUsername; + private char[] authPassword; + private char[] authApiKey; + private String authApiKeyName; + private String authApiKeyIn; + private Map authCustomHeaders = Collections.emptyMap(); + private String connectTimeout; + private String responseTimeout; + private String httpVersion; + private String redirectPolicy; + private String proxyHost; + private Integer proxyPort; + + public Builder apiName(String apiName) { + this.apiName = apiName; + return this; + } + + public Builder apiBaseUrl(String apiBaseUrl) { + this.apiBaseUrl = apiBaseUrl; + return this; + } + + public Builder apiSpec(String apiSpec) { + this.apiSpec = apiSpec; + return this; + } + + public Builder authType(String authType) { + this.authType = authType; + return this; + } + + public Builder authToken(char[] authToken) { + this.authToken = authToken; + return this; + } + + public Builder authUsername(String authUsername) { + this.authUsername = authUsername; + return this; + } + + public Builder authPassword(char[] authPassword) { + this.authPassword = authPassword; + return this; + } + + public Builder authApiKey(char[] authApiKey) { + this.authApiKey = authApiKey; + return this; + } + + public Builder authApiKeyName(String authApiKeyName) { + this.authApiKeyName = authApiKeyName; + return this; + } + + public Builder authApiKeyIn(String authApiKeyIn) { + this.authApiKeyIn = authApiKeyIn; + return this; + } + + public Builder authCustomHeaders(Map headers) { + this.authCustomHeaders = headers; + return this; + } + + public Builder connectTimeout(String connectTimeout) { + this.connectTimeout = connectTimeout; + return this; + } + + public Builder responseTimeout(String responseTimeout) { + this.responseTimeout = responseTimeout; + return this; + } + + public Builder httpVersion(String httpVersion) { + this.httpVersion = httpVersion; + return this; + } + + public Builder redirectPolicy(String redirectPolicy) { + this.redirectPolicy = redirectPolicy; + return this; + } + + public Builder proxyHost(String proxyHost) { + this.proxyHost = proxyHost; + return this; + } + + public Builder proxyPort(Integer proxyPort) { + this.proxyPort = proxyPort; + return this; + } + + public McpServerConfig build() { + return new McpServerConfig(this); + } + } + + public static Builder builder() { + return new Builder(); + } // ----------------- FACTORY METHOD ----------------- public static McpServerConfig fromArgs(String[] args) throws McpServerToolInitializeException { Map argMap = toMap(args); - // ----------------- API info ----------------- + // API info String apiName = getStringValue(argMap.get("--api-name"), "API_NAME"); String apiBaseUrl = getStringValue(argMap.get("--api-base-url"), "API_BASE_URL"); + if (apiBaseUrl == null) throw new McpServerToolInitializeException(ErrorMessage.MISSING_API_BASE_URL); + String apiSpec = getStringValue(argMap.get("--api-spec"), "API_SPEC"); + if (apiSpec == null) throw new McpServerToolInitializeException(ErrorMessage.MISSING_API_SPEC); - if (apiSpec == null) { - throw new McpServerToolInitializeException(ErrorMessage.MISSING_API_SPEC); - } - // ----------------- Authentication ----------------- + // Authentication String authType = getStringValue(argMap.get("--auth-type"), "AUTH_TYPE"); char[] authToken = getCharValue(argMap.get("--auth-token"), "AUTH_TOKEN"); String authUsername = getStringValue(argMap.get("--auth-username"), "AUTH_USERNAME"); char[] authPassword = getCharValue(argMap.get("--auth-password"), "AUTH_PASSWORD"); char[] authApiKey = getCharValue(argMap.get("--auth-api-key"), "AUTH_API_KEY"); - String authApiKeyName = getStringValue(argMap.get("--auth-api-key-name"), "AUTH_API_KEY_NAME"); - String authApiKeyIn = getStringValue(argMap.get("--auth-api-key-in"), "AUTH_API_KEY_IN"); + String authApiKeyName = getStringValue(argMap.get("--auth-api-key-name"), "API_API_KEY_NAME"); + String authApiKeyIn = getStringValue(argMap.get("--auth-api-key-in"), "API_API_KEY_IN"); + - // ----------------- Network configs ----------------- - String connectTimeout = getStringValue(argMap.get("--connect-timeout"), "API_HTTP_CONNECT_TIMEOUT"); - if (connectTimeout == null) connectTimeout = "10000"; // default 10s in ms + // Validation for API key + if ("API_KEY".equalsIgnoreCase(authType)) { + if (authApiKey == null || authApiKey.length == 0) { + throw new McpServerToolInitializeException("Missing API Key value for auth type API_KEY"); + } + if (authApiKeyName == null || authApiKeyName.isBlank()) { + throw new McpServerToolInitializeException("Missing API Key name (--auth-api-key-name) for auth type API_KEY"); + } + if (authApiKeyIn == null || + !(authApiKeyIn.equalsIgnoreCase("header") || authApiKeyIn.equalsIgnoreCase("query"))) { + throw new McpServerToolInitializeException("Invalid or missing API Key location (--auth-api-key-in). Must be 'header' or 'query'."); + } + } - String responseTimeout = getStringValue(argMap.get("--response-timeout"), "API_HTTP_RESPONSE_TIMEOUT"); - if (responseTimeout == null) responseTimeout = "30000"; // default 30s in ms + // Validation for Basic auth + if ("BASIC".equalsIgnoreCase(authType)) { + if (authUsername == null || authUsername.isBlank()) { + throw new McpServerToolInitializeException("Missing username for BASIC auth"); + } + if (authPassword == null || authPassword.length == 0) { + throw new McpServerToolInitializeException("Missing password for BASIC auth"); + } + } - String httpVersion = getStringValue(argMap.get("--http-version"), "API_HTTP_VERSION"); - if (httpVersion == null) httpVersion = "HTTP_2"; + // Validation for Bearer token + if ("BEARER".equalsIgnoreCase(authType)) { + if (authToken == null || authToken.length == 0) { + throw new McpServerToolInitializeException("Missing bearer token for BEARER auth"); + } + } - String redirectPolicy = getStringValue(argMap.get("--http-redirect"), "API_HTTP_REDIRECT"); - if (redirectPolicy == null) redirectPolicy = "NORMAL"; + // Parse custom headers JSON + String customHeadersJson = getStringValue(argMap.get("--auth-custom-headers"), "AUTH_CUSTOM_HEADERS"); + Map authCustomHeaders = Collections.emptyMap(); + if (customHeadersJson != null && !customHeadersJson.isEmpty()) { + try { + authCustomHeaders = OBJECT_MAPPER.readValue(customHeadersJson, new TypeReference<>() { + }); + } catch (JsonProcessingException e) { + throw new McpServerToolInitializeException("Invalid JSON format for --auth-custom-headers: " + e.getMessage()); + } + } + // Network configs + String connectTimeout = getStringValue(argMap.getOrDefault("--connect-timeout", "10000"), "API_HTTP_CONNECT_TIMEOUT"); + String responseTimeout = getStringValue(argMap.getOrDefault("--response-timeout", "30000"), "API_HTTP_RESPONSE_TIMEOUT"); + String httpVersion = getStringValue(argMap.getOrDefault("--http-version", "HTTP_2"), "API_HTTP_VERSION"); + String redirectPolicy = getStringValue(argMap.getOrDefault("--http-redirect", "NORMAL"), "API_HTTP_REDIRECT"); String proxyHost = getStringValue(argMap.get("--proxy-host"), "API_HTTP_PROXY_HOST"); Integer proxyPort = getIntOrNull(argMap.get("--proxy-port"), "API_HTTP_PROXY_PORT"); - return new McpServerConfig(apiName, apiBaseUrl, - authType, authToken, authUsername, authPassword, authApiKey, - authApiKeyName, authApiKeyIn, apiSpec, - connectTimeout, responseTimeout, - httpVersion, redirectPolicy, proxyHost, proxyPort); + // Build config using builder + return McpServerConfig.builder() + .apiName(apiName) + .apiBaseUrl(apiBaseUrl) + .apiSpec(apiSpec) + .authType(authType) + .authToken(authToken) + .authUsername(authUsername) + .authPassword(authPassword) + .authApiKey(authApiKey) + .authApiKeyName(authApiKeyName) + .authApiKeyIn(authApiKeyIn) + .authCustomHeaders(authCustomHeaders) + .connectTimeout(connectTimeout) + .responseTimeout(responseTimeout) + .httpVersion(httpVersion) + .redirectPolicy(redirectPolicy) + .proxyHost(proxyHost) + .proxyPort(proxyPort) + .build(); } // ----------------- HELPERS ----------------- @@ -164,8 +386,7 @@ private static String getStringValue(String cliValue, String envVarName) { } private static Integer getIntOrNull(String cliValue, String envVarName) { - String envValue = System.getenv(envVarName); - String value = envValue != null ? envValue : cliValue; + String value = getStringValue(cliValue, envVarName); if (value != null) { try { return Integer.parseInt(value); @@ -178,16 +399,17 @@ private static Integer getIntOrNull(String cliValue, String envVarName) { private static Map toMap(String[] args) { Map map = new HashMap<>(); + if (args == null) return map; for (int i = 0; i < args.length; i++) { String key = args[i]; if (key.startsWith("--")) { if (i + 1 < args.length && !args[i + 1].startsWith("--")) { map.put(key, args[++i]); } else { - map.put(key, null); // flag with no value + map.put(key, ""); // Use empty string for flags without values } } else { - throw new IllegalArgumentException("Unexpected argument: " + key); + System.err.println("Warning: Unexpected argument format, ignoring: " + key); } } return map; diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/HttpClientManager.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/HttpClientManager.java index 05b6b449..1e2e18eb 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/HttpClientManager.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/HttpClientManager.java @@ -1,3 +1,9 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ package com.oracle.mcp.openapi.rest; import com.oracle.mcp.openapi.model.McpServerConfig; @@ -7,21 +13,51 @@ import java.net.http.HttpClient; import java.time.Duration; +/** + * Utility class responsible for creating and configuring {@link HttpClient} instances + * based on the properties defined in a {@link McpServerConfig}. + *

+ * This class centralizes client configuration such as connection timeout, HTTP version, + * redirect handling, and proxy settings, ensuring consistent HTTP client creation + * across the application. + * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ public class HttpClientManager { - + /** + * Creates a new instance of {@code HttpClientManager}. + *

+ * The constructor is empty since this class only provides factory-style behavior. + */ public HttpClientManager() { - } + /** + * Builds and returns a configured {@link HttpClient} using the values provided + * in the given {@link McpServerConfig}. + *

+ * The configuration can include: + *

    + *
  • Connection timeout (in milliseconds)
  • + *
  • HTTP version (HTTP/1.1 or HTTP/2)
  • + *
  • Redirect policy (NEVER, NORMAL, ALWAYS)
  • + *
  • Proxy host and port
  • + *
+ * + * @param config the server configuration containing HTTP client settings + * @return a configured {@link HttpClient} instance + * @throws IllegalArgumentException if an unsupported redirect policy is provided + */ public HttpClient getClient(McpServerConfig config) { HttpClient.Builder builder = HttpClient.newBuilder(); + // Connection timeout if (config.getConnectTimeout() != null) { builder.connectTimeout(Duration.ofMillis(config.getConnectTimeoutMs())); } - + // HTTP version if (config.getHttpVersion() != null) { if (config.getHttpVersion().equalsIgnoreCase("HTTP_2")) { builder.version(HttpClient.Version.HTTP_2); @@ -30,6 +66,7 @@ public HttpClient getClient(McpServerConfig config) { } } + // Redirect policy if (config.getRedirectPolicy() != null) { switch (config.getRedirectPolicy().toUpperCase()) { case "NEVER": diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiExecutionService.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiExecutionService.java index 226bc27b..74f7970e 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiExecutionService.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiExecutionService.java @@ -1,3 +1,9 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ package com.oracle.mcp.openapi.rest; import com.oracle.mcp.openapi.cache.McpServerCacheService; @@ -13,49 +19,84 @@ import java.util.Map; import java.util.stream.Stream; +/** + * Service for executing REST API requests using Java's {@link HttpClient}. + *

+ * This class provides a generic request execution method + * ({@link #executeRequest(String, String, String, Map)}) + * and convenience methods for common HTTP verbs (e.g., {@link #get(String, Map)}). + *

+ * The HTTP client is lazily initialized using configuration stored in + * {@link McpServerCacheService} via {@link McpServerConfig}. + * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ public class RestApiExecutionService { + /** + * Lazily initialized {@link HttpClient} instance. + */ private HttpClient httpClient; + + /** + * Service providing cached server configuration details used to + * initialize the {@link HttpClient}. + */ private final McpServerCacheService mcpServerCacheService; - // HTTP Method constants + /** Constant for HTTP GET method. */ public static final String GET = "GET"; + /** Constant for HTTP POST method. */ public static final String POST = "POST"; + /** Constant for HTTP PUT method. */ public static final String PUT = "PUT"; - public static final String DELETE = "DELETE"; + /** Constant for HTTP PATCH method. */ public static final String PATCH = "PATCH"; - + /** + * Constructs a new {@code RestApiExecutionService}. + * + * @param mcpServerCacheService cache service providing server configuration + */ public RestApiExecutionService(McpServerCacheService mcpServerCacheService) { - this.mcpServerCacheService = mcpServerCacheService; + this.mcpServerCacheService = mcpServerCacheService; } + /** + * Returns a configured {@link HttpClient} instance. + *

+ * If the client has already been initialized, it is returned directly. + * Otherwise, a new client is created using the cached {@link McpServerConfig}. + * + * @return an {@link HttpClient} ready for use + */ private HttpClient getHttpClient() { - if(this.httpClient!=null){ + if (this.httpClient != null) { return this.httpClient; } McpServerConfig mcpServerConfig = mcpServerCacheService.getServerConfig(); return new HttpClientManager().getClient(mcpServerConfig); - } /** - * Executes an HTTP request. + * Executes an HTTP request against the target URL with the specified method, + * request body, and headers. * - * @param targetUrl the URL to call - * @param method HTTP method (GET, POST, etc.) - * @param body Request body (only for POST, PUT, PATCH) - * @param headers Optional headers - * @return the response as String - * @throws IOException if an I/O error occurs - * @throws InterruptedException if the operation is interrupted + * @param targetUrl the URL to call (must be a valid absolute URI) + * @param method HTTP method (GET, POST, PUT, PATCH, DELETE) + * @param body request body content, used only for POST, PUT, or PATCH methods; + * ignored for GET and DELETE + * @param headers optional request headers; may be {@code null} or empty + * @return the response body as a {@link String} + * @throws IOException if an I/O error occurs while sending or receiving + * @throws InterruptedException if the operation is interrupted while waiting */ public String executeRequest(String targetUrl, String method, String body, Map headers) throws IOException, InterruptedException { HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() .uri(URI.create(targetUrl)) - .version(HttpClient.Version.HTTP_1_1) // force 1.1 + .version(HttpClient.Version.HTTP_1_1) // force HTTP/1.1 .timeout(Duration.ofSeconds(30)); // Add headers @@ -74,28 +115,22 @@ public String executeRequest(String targetUrl, String method, String body, Map response = getHttpClient().send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + HttpResponse response = + getHttpClient().send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); return response.body(); } + /** + * Executes a simple HTTP GET request. + * + * @param url the target URL + * @param headers optional request headers; may be {@code null} or empty + * @return the response body as a {@link String} + * @throws IOException if an I/O error occurs while sending or receiving + * @throws InterruptedException if the operation is interrupted while waiting + */ public String get(String url, Map headers) throws IOException, InterruptedException { return executeRequest(url, GET, null, headers); } - - public String post(String url, String body, Map headers) throws IOException, InterruptedException { - return executeRequest(url, POST, body, headers); - } - - public String put(String url, String body, Map headers) throws IOException, InterruptedException { - return executeRequest(url, PUT, body, headers); - } - - public String delete(String url, Map headers) throws IOException, InterruptedException { - return executeRequest(url, DELETE, null, headers); - } - - public String patch(String url, String body, Map headers) throws IOException, InterruptedException { - return executeRequest(url, PATCH, body, headers); - } } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutor.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutor.java new file mode 100644 index 00000000..571903c9 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutor.java @@ -0,0 +1,238 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ +package com.oracle.mcp.openapi.tool; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.oracle.mcp.openapi.cache.McpServerCacheService; +import com.oracle.mcp.openapi.enums.OpenApiSchemaAuthType; +import com.oracle.mcp.openapi.model.McpServerConfig; +import com.oracle.mcp.openapi.rest.RestApiExecutionService; +import io.modelcontextprotocol.server.McpSyncServerExchange; +import io.modelcontextprotocol.spec.McpSchema; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.*; + +/** + * Executes OpenAPI-based MCP tools. This class translates MCP tool requests + * into actual HTTP REST API calls, handling path parameters, query parameters, + * authentication, and headers automatically. + * + *

+ * It supports multiple authentication mechanisms (NONE, BASIC, BEARER, API_KEY, CUSTOM) + * and can dynamically substitute parameters based on the tool metadata. + *

+ * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ +public class OpenApiMcpToolExecutor { + + private static final Logger LOGGER = LoggerFactory.getLogger(OpenApiMcpToolExecutor.class); + + private final McpServerCacheService mcpServerCacheService; + private final RestApiExecutionService restApiExecutionService; + private final ObjectMapper jsonMapper; + + /** + * Constructs a new {@code OpenApiMcpToolExecutor}. + * + * @param mcpServerCacheService service for retrieving cached tool and server configurations + * @param restApiExecutionService service for executing REST API requests + * @param jsonMapper JSON object mapper for serializing request bodies + */ + public OpenApiMcpToolExecutor(McpServerCacheService mcpServerCacheService, + RestApiExecutionService restApiExecutionService, + ObjectMapper jsonMapper) { + this.mcpServerCacheService = mcpServerCacheService; + this.restApiExecutionService = restApiExecutionService; + this.jsonMapper = jsonMapper; + } + + /** + * Executes a tool request coming from a synchronous MCP server exchange. + * + * @param exchange the MCP exchange context + * @param callRequest the tool execution request + * @return the result of executing the tool + */ + public McpSchema.CallToolResult execute(McpSyncServerExchange exchange, McpSchema.CallToolRequest callRequest) { + return execute(callRequest); + } + + /** + * Executes a tool request directly, without exchange context. + * + *

+ * Resolves path parameters, query parameters, headers, and authentication, + * then executes the HTTP request and returns the response wrapped as structured content. + *

+ * + * @param callRequest the tool execution request + * @return the result of executing the tool + */ + public McpSchema.CallToolResult execute(McpSchema.CallToolRequest callRequest) { + String response; + try { + McpSchema.Tool toolToExecute = mcpServerCacheService.getTool(callRequest.name()); + String httpMethod = toolToExecute.meta().get("httpMethod").toString().toUpperCase(); + String path = toolToExecute.meta().get("path").toString(); + McpServerConfig config = mcpServerCacheService.getServerConfig(); + + Map arguments = new HashMap<>(callRequest.arguments()); + + // Resolve final URL with substituted path and query parameters + String finalUrl = substitutePathParameters(config.getApiBaseUrl() + path, toolToExecute, arguments); + finalUrl = appendQueryParameters(finalUrl, toolToExecute, arguments, config); + + // Prepare headers and request body + Map headers = prepareHeaders(config); + String body = null; + if (shouldHaveBody(httpMethod)) { + body = jsonMapper.writeValueAsString(arguments); + headers.put("Content-Type", "application/json"); + } + + LOGGER.debug("Executing {} request to URL: {}", httpMethod, finalUrl); + response = restApiExecutionService.executeRequest(finalUrl, httpMethod, body, headers); + LOGGER.info("Successfully executed tool '{}'.", callRequest.name()); + + } catch (IOException | InterruptedException e) { + LOGGER.error("Execution failed for tool '{}': {}", callRequest.name(), e.getMessage()); + throw new RuntimeException("Failed to execute tool: " + callRequest.name(), e); + } + + String wrappedResponse = "{\"response\":" + response + "}"; + return McpSchema.CallToolResult.builder() + .structuredContent(wrappedResponse) + .build(); + } + + /** + * Substitutes path parameters (e.g., {@code /users/{id}}) in the URL with actual values + * from the request arguments. + * + * @param url the URL containing path placeholders + * @param tool the tool definition containing metadata + * @param arguments the request arguments + * @return the final URL with substituted path parameters + */ + private String substitutePathParameters(String url, McpSchema.Tool tool, Map arguments) { + Map pathParams = (Map) tool.meta().getOrDefault("pathParams", Collections.emptyMap()); + String finalUrl = url; + for (String paramName : pathParams.keySet()) { + if (arguments.containsKey(paramName)) { + String value = String.valueOf(arguments.get(paramName)); + finalUrl = finalUrl.replace("{" + paramName + "}", URLEncoder.encode(value, StandardCharsets.UTF_8)); + arguments.remove(paramName); + } + } + return finalUrl; + } + + /** + * Appends query parameters (including API key if configured) to the URL. + * + * @param url the base URL + * @param tool the tool definition containing metadata + * @param arguments the request arguments + * @param config the server configuration (used for API key handling) + * @return the final URL with query parameters appended + */ + private String appendQueryParameters(String url, McpSchema.Tool tool, Map arguments, McpServerConfig config) { + Map queryParams = (Map) tool.meta().getOrDefault("queryParams", Collections.emptyMap()); + List queryParts = new ArrayList<>(); + + // Add regular query parameters + queryParams.keySet().stream() + .filter(arguments::containsKey) + .map(paramName -> { + String key = URLEncoder.encode(paramName, StandardCharsets.UTF_8); + String value = URLEncoder.encode(String.valueOf(arguments.get(paramName)), StandardCharsets.UTF_8); + arguments.remove(paramName); + return key + "=" + value; + }) + .forEach(queryParts::add); + + // Add API key if configured to go in query + if (config.getAuthType() == OpenApiSchemaAuthType.API_KEY && "query".equalsIgnoreCase(config.getAuthApiKeyIn())) { + String key = URLEncoder.encode(config.getAuthApiKeyName(), StandardCharsets.UTF_8); + String value = URLEncoder.encode(new String(Objects.requireNonNull(config.getAuthApiKey())), StandardCharsets.UTF_8); + queryParts.add(key + "=" + value); + } + + if (queryParts.isEmpty()) { + return url; + } + + String queryPart = String.join("&", queryParts); + return url + (url.contains("?") ? "&" : "?") + queryPart; + } + + /** + * Prepares HTTP headers, including authentication headers based on server configuration. + * + * @param config the server configuration + * @return a map of HTTP headers + */ + private Map prepareHeaders(McpServerConfig config) { + Map headers = new HashMap<>(); + headers.put("Accept", "application/json"); + + OpenApiSchemaAuthType authType = config.getAuthType(); + if (authType == null) { + authType = OpenApiSchemaAuthType.NONE; + } + + switch (authType) { + case NONE: + break; + case BASIC: + char[] passwordChars = config.getAuthPassword(); + assert passwordChars != null; + String password = new String(passwordChars); + String encoded = Base64.getEncoder().encodeToString( + (config.getAuthUsername() + ":" + password).getBytes(StandardCharsets.UTF_8) + ); + headers.put("Authorization", "Basic " + encoded); + Arrays.fill(passwordChars, ' '); + break; + case BEARER: + char[] tokenChars = config.getAuthToken(); + assert tokenChars != null; + String token = new String(tokenChars); + headers.put("Authorization", "Bearer " + token); + Arrays.fill(tokenChars, ' '); + break; + case API_KEY: + if ("header".equalsIgnoreCase(config.getAuthApiKeyIn())) { + headers.put(config.getAuthApiKeyName(), new String(Objects.requireNonNull(config.getAuthApiKey()))); + } + break; + case CUSTOM: + headers.putAll(config.getAuthCustomHeaders()); + break; + } + return headers; + } + + /** + * Determines whether the HTTP request should have a body. + * + * @param httpMethod the HTTP method (e.g., GET, POST, PUT, PATCH) + * @return true if the method supports a request body, false otherwise + */ + private boolean shouldHaveBody(String httpMethod) { + return switch (httpMethod.toUpperCase()) { + case "POST", "PUT", "PATCH" -> true; + default -> false; + }; + } +} diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolInitializer.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolInitializer.java new file mode 100644 index 00000000..0974fae1 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolInitializer.java @@ -0,0 +1,106 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ +package com.oracle.mcp.openapi.tool; + +import com.fasterxml.jackson.databind.JsonNode; +import com.oracle.mcp.openapi.cache.McpServerCacheService; +import com.oracle.mcp.openapi.exception.UnsupportedApiDefinitionException; +import com.oracle.mcp.openapi.mapper.impl.OpenApiToMcpToolMapper; +import com.oracle.mcp.openapi.mapper.impl.SwaggerToMcpToolMapper; +import io.modelcontextprotocol.spec.McpSchema; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; + +/** + * Initializes and extracts {@link McpSchema.Tool} objects from OpenAPI or Swagger specifications. + *

+ * This class detects whether the provided API definition is OpenAPI (v3) or Swagger (v2), + * maps the specification into {@link McpSchema.Tool} objects, and updates the + * {@link McpServerCacheService} with the extracted tools for later use. + *

+ * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ +public class OpenApiMcpToolInitializer { + + private final McpServerCacheService mcpServerCacheService; + private static final Logger LOGGER = LoggerFactory.getLogger(OpenApiMcpToolInitializer.class); + + /** + * Creates a new {@code OpenApiMcpToolInitializer}. + * + * @param mcpServerCacheService the cache service for storing and retrieving tool definitions + */ + public OpenApiMcpToolInitializer(McpServerCacheService mcpServerCacheService) { + this.mcpServerCacheService = mcpServerCacheService; + } + + /** + * Extracts tools from the given OpenAPI/Swagger JSON definition. + *

+ * Determines the API specification type (OpenAPI or Swagger), + * maps it to {@link McpSchema.Tool} objects, caches them, + * and returns the list of tools. + *

+ * + * @param openApiJson the JSON representation of the OpenAPI or Swagger specification + * @return a list of {@link McpSchema.Tool} created from the API definition + * @throws IllegalArgumentException if {@code openApiJson} is {@code null} + * @throws UnsupportedApiDefinitionException if the API definition is not recognized + */ + public List extractTools(JsonNode openApiJson) { + LOGGER.debug("Parsing OpenAPI JsonNode to OpenAPI object..."); + List mcpTools = parseApi(openApiJson); + + LOGGER.debug("Conversion complete. Total tools created: {}", mcpTools.size()); + updateToolsToCache(mcpTools); + return mcpTools; + } + + /** + * Updates the {@link McpServerCacheService} with the given tools. + * + * @param tools the tools to cache + */ + private void updateToolsToCache(List tools) { + for (McpSchema.Tool tool : tools) { + mcpServerCacheService.putTool(tool.name(), tool); + } + } + + /** + * Parses the given JSON node into a list of {@link McpSchema.Tool} objects. + *

+ * Detects the specification type: + *

    + *
  • If {@code openapi} field exists, assumes OpenAPI 3.x
  • + *
  • If {@code swagger} field exists, assumes Swagger 2.x
  • + *
  • Otherwise, throws {@link UnsupportedApiDefinitionException}
  • + *
+ *

+ * + * @param jsonNode the JSON representation of the API definition + * @return a list of mapped {@link McpSchema.Tool} objects + * @throws IllegalArgumentException if {@code jsonNode} is {@code null} + * @throws UnsupportedApiDefinitionException if the specification type is unsupported + */ + private List parseApi(JsonNode jsonNode) { + if (jsonNode == null) { + throw new IllegalArgumentException("jsonNode cannot be null"); + } + // Detect version + if (jsonNode.has("openapi")) { + return new OpenApiToMcpToolMapper(mcpServerCacheService).convert(jsonNode); + } else if (jsonNode.has("swagger")) { + return new SwaggerToMcpToolMapper(mcpServerCacheService).convert(jsonNode); + } else { + throw new UnsupportedApiDefinitionException("Unsupported API definition: missing 'openapi' or 'swagger' field"); + } + } +} diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiToMcpToolConverter.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiToMcpToolConverter.java deleted file mode 100644 index 5b000a1f..00000000 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiToMcpToolConverter.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.oracle.mcp.openapi.tool; - -import com.fasterxml.jackson.databind.JsonNode; -import com.oracle.mcp.openapi.cache.McpServerCacheService; -import com.oracle.mcp.openapi.mapper.impl.OpenApiToMcpToolMapper; -import com.oracle.mcp.openapi.mapper.impl.SwaggerToMcpToolMapper; -import io.modelcontextprotocol.spec.McpSchema; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import java.util.*; - -public class OpenApiToMcpToolConverter { - - private final McpServerCacheService mcpServerCacheService; - private static final Logger LOGGER = LoggerFactory.getLogger(OpenApiToMcpToolConverter.class); - - public OpenApiToMcpToolConverter(McpServerCacheService mcpServerCacheService) { - this.mcpServerCacheService = mcpServerCacheService; - } - - public List convertJsonToMcpTools(JsonNode openApiJson) { - LOGGER.debug("Parsing OpenAPI JsonNode to OpenAPI object..."); - List mcpTools = parseApi(openApiJson); - - LOGGER.debug("Conversion complete. Total tools created: {}", mcpTools.size()); - updateToolsToCache(mcpTools); - return mcpTools; - } - - - private void updateToolsToCache(List tools) { - for (McpSchema.Tool tool : tools) { - mcpServerCacheService.putTool(tool.name(), tool); - } - } - - private List parseApi(JsonNode jsonNode) { - if (jsonNode == null) { - throw new IllegalArgumentException("jsonNode cannot be null"); - } - // Detect version - if (jsonNode.has("openapi")) { - - - return new OpenApiToMcpToolMapper(mcpServerCacheService).convert(jsonNode); - - } else if (jsonNode.has("swagger")) { - - return new SwaggerToMcpToolMapper(mcpServerCacheService).convert(jsonNode); - - } else { - throw new IllegalArgumentException("Unsupported API definition: missing 'openapi' or 'swagger' field"); - } - } - -} diff --git a/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/OpenApiMcpServerTest.java b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/OpenApiMcpServerTest.java new file mode 100644 index 00000000..aaa5d6fd --- /dev/null +++ b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/OpenApiMcpServerTest.java @@ -0,0 +1,185 @@ +package com.oracle.mcp.openapi; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.tomakehurst.wiremock.WireMockServer; +import com.github.tomakehurst.wiremock.core.WireMockConfiguration; +import com.oracle.mcp.openapi.cache.McpServerCacheService; +import com.oracle.mcp.openapi.model.McpServerConfig; +import com.oracle.mcp.openapi.rest.RestApiExecutionService; +import com.oracle.mcp.openapi.tool.OpenApiMcpToolExecutor; +import io.modelcontextprotocol.server.McpAsyncServer; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.ApplicationContext; + +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Map; +import java.util.concurrent.CopyOnWriteArrayList; + +import static org.junit.jupiter.api.Assertions.*; + +@SpringBootTest( + args = { + "--api-spec", "http://localhost:8080/rest/v1/metadata-catalog/companies", + "--api-base-url", "http://localhost:8080" + } +) +class OpenApiMcpServerTest { + + @Autowired + private McpSyncServer mcpSyncServer; + + @Autowired + private RestApiExecutionService restApiExecutionService; + + @Autowired + private McpServerCacheService mcpServerCacheService; + + @Autowired + private ApplicationContext context; + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + private static String expectedTools; + private static String getAllCompaniesToolResponse; + private static String getOneCompanyToolResponse; + private static String getUpdateCompanyToolResponse; + + @BeforeAll + static void setup() throws Exception { + // Start WireMock once + WireMockServer wireMockServer = new WireMockServer(WireMockConfiguration.options() + .port(8080) + .usingFilesUnderDirectory("src/test/resources")); + wireMockServer.start(); + + // Load test resources once + expectedTools = readFile("src/test/resources/tools/listTool.json"); + getAllCompaniesToolResponse = readFile("src/test/resources/__files/companies-response.json"); + getOneCompanyToolResponse = readFile("src/test/resources/__files/company-1-response.json"); + getUpdateCompanyToolResponse = getOneCompanyToolResponse; + System.out.println("WireMock server started on port 8080"); + } + + private static String readFile(String path) throws Exception { + String content = Files.readString(Paths.get(path)); + assertNotNull(content, "File not found: " + path); + return content; + } + + @Test + void testListTools() throws Exception { + McpAsyncServer asyncServer = mcpSyncServer.getAsyncServer(); + + Field toolsField = asyncServer.getClass().getDeclaredField("tools"); + toolsField.setAccessible(true); + CopyOnWriteArrayList tools = (CopyOnWriteArrayList) toolsField.get(asyncServer); + + assertEquals(4, tools.size()); + assertEquals(expectedTools, objectMapper.writeValueAsString(tools)); + } + + @Test + void testExecuteGetAllTools_BasicAuth() throws Exception { + McpServerCacheService cacheService = mockConfig( + McpServerConfig.builder() + .apiBaseUrl("http://localhost:8080") + .authType("BASIC") + .authUsername("test-user") + .authPassword("test-password".toCharArray()) + .build(), + "getCompanies" + ); + + OpenApiMcpToolExecutor executor = new OpenApiMcpToolExecutor(cacheService, restApiExecutionService, objectMapper); + McpSchema.CallToolRequest request = new McpSchema.CallToolRequest("getCompanies", "{}"); + McpSchema.CallToolResult result = executor.execute(request); + + String response = objectMapper.writeValueAsString(result.structuredContent().get("response")); + assertEquals(getAllCompaniesToolResponse, response); + } + + @Test + void testExecuteGetAllTools_BearerAuth() throws Exception { + McpServerCacheService cacheService = mockConfig( + McpServerConfig.builder() + .apiBaseUrl("http://localhost:8080") + .authType("BEARER") + .authToken("test-token".toCharArray()) + .build(), + "getCompanyById" + ); + + OpenApiMcpToolExecutor executor = new OpenApiMcpToolExecutor(cacheService, restApiExecutionService, objectMapper); + McpSchema.CallToolRequest request = new McpSchema.CallToolRequest("getCompanyById", "{\"companyId\":1}"); + McpSchema.CallToolResult result = executor.execute(request); + + String response = objectMapper.writeValueAsString(result.structuredContent().get("response")); + assertEquals(getOneCompanyToolResponse, response); + } + + @Test + void testExecuteCreateCompany_ApiKeyAuth() throws Exception { + McpServerCacheService cacheService = mockConfig( + McpServerConfig.builder() + .apiBaseUrl("http://localhost:8080") + .authType("API_KEY") + .authApiKeyIn("HEADER") + .authApiKeyName("X-API-KEY") + .authApiKey("test-api-key".toCharArray()) // <- new field in your config + .build(), + "createCompany" + ); + + OpenApiMcpToolExecutor executor = new OpenApiMcpToolExecutor(cacheService, restApiExecutionService, objectMapper); + McpSchema.CallToolRequest request = new McpSchema.CallToolRequest( + "createCompany", + "{ \"name\": \"Test Company\", \"address\": \"123 Main St\" }" + ); + McpSchema.CallToolResult result = executor.execute(request); + + String response = objectMapper.writeValueAsString(result.structuredContent().get("response")); + assertEquals(getOneCompanyToolResponse, response); // should match __files/company-1-response.json + } + + @Test + void testExecuteUpdateCompany_CustomAuth() throws Exception { + McpServerCacheService cacheService = mockConfig( + McpServerConfig.builder() + .apiBaseUrl("http://localhost:8080") + .authType("CUSTOM") + .authCustomHeaders(Map.of("CUSTOM-HEADER","test-custom-key")) + .build(), + "updateCompany" + ); + + OpenApiMcpToolExecutor executor = new OpenApiMcpToolExecutor(cacheService, restApiExecutionService, objectMapper); + + McpSchema.CallToolRequest request = new McpSchema.CallToolRequest( + "updateCompany", + "{ \"companyId\": 1, \"name\": \"Acme Corp - Updated\", \"industry\": \"Technology\" }" + ); + + McpSchema.CallToolResult result = executor.execute(request); + + String response = objectMapper.writeValueAsString(result.structuredContent().get("response")); + assertEquals(getUpdateCompanyToolResponse, response); + } + + + + + private McpServerCacheService mockConfig(McpServerConfig config, String toolName) { + McpServerCacheService mockCache = Mockito.mock(McpServerCacheService.class); + Mockito.when(mockCache.getServerConfig()).thenReturn(config); + Mockito.when(mockCache.getTool(toolName)).thenReturn(this.mcpServerCacheService.getTool(toolName)); + return mockCache; + } +} diff --git a/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/model/McpServerConfigTest.java b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/model/McpServerConfigTest.java new file mode 100644 index 00000000..d16abe82 --- /dev/null +++ b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/model/McpServerConfigTest.java @@ -0,0 +1,213 @@ +package com.oracle.mcp.openapi.model; + +import com.oracle.mcp.openapi.constants.ErrorMessage; +import com.oracle.mcp.openapi.exception.McpServerToolInitializeException; +import org.junit.jupiter.api.Test; + +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class McpServerConfigTest { + + @Test + void fromArgs_whenAllCliArgsProvided_thenConfigIsCreatedCorrectly() throws McpServerToolInitializeException { + String[] args = { + "--api-name", "MyTestApi", + "--api-base-url", "https://api.example.com", + "--api-spec", "path/to/spec.json", + "--auth-type", "CUSTOM", + "--auth-custom-headers", "{\"X-Custom-Auth\":\"secret-value\"}", + "--connect-timeout", "5000", + "--response-timeout", "15000", + "--http-version", "HTTP_1_1", + "--http-redirect", "ALWAYS", + "--proxy-host", "proxy.example.com", + "--proxy-port", "8080" + }; + + McpServerConfig config = McpServerConfig.fromArgs(args); + + assertThat(config.getApiName()).isEqualTo("MyTestApi"); + assertThat(config.getApiBaseUrl()).isEqualTo("https://api.example.com"); + assertThat(config.getApiSpec()).isEqualTo("path/to/spec.json"); + assertThat(config.getRawAuthType()).isEqualTo("CUSTOM"); + assertThat(config.getAuthCustomHeaders()).isEqualTo(Map.of("X-Custom-Auth", "secret-value")); + assertThat(config.getConnectTimeout()).isEqualTo("5000"); + assertThat(config.getConnectTimeoutMs()).isEqualTo(5000L); + assertThat(config.getResponseTimeout()).isEqualTo("15000"); + assertThat(config.getResponseTimeoutMs()).isEqualTo(15000L); + assertThat(config.getHttpVersion()).isEqualTo("HTTP_1_1"); + assertThat(config.getRedirectPolicy()).isEqualTo("ALWAYS"); + assertThat(config.getProxyHost()).isEqualTo("proxy.example.com"); + assertThat(config.getProxyPort()).isEqualTo(8080); + } + + @Test + void fromArgs_whenOptionalNetworkArgsAreMissing_thenDefaultsAreUsed() throws McpServerToolInitializeException { + String[] args = { + "--api-base-url", "https://api.example.com", + "--api-spec", "spec.json" + }; + + McpServerConfig config = McpServerConfig.fromArgs(args); + + assertThat(config.getConnectTimeout()).isEqualTo("10000"); + assertThat(config.getResponseTimeout()).isEqualTo("30000"); + assertThat(config.getHttpVersion()).isEqualTo("HTTP_2"); + assertThat(config.getRedirectPolicy()).isEqualTo("NORMAL"); + assertThat(config.getProxyHost()).isNull(); + assertThat(config.getProxyPort()).isNull(); + } + + @Test + void fromArgs_whenNoArgsProvided_thenThrowsException() { + String[] args = {}; + assertThatThrownBy(() -> McpServerConfig.fromArgs(args)) + .isInstanceOf(McpServerToolInitializeException.class) + .hasMessage(ErrorMessage.MISSING_API_BASE_URL); + } + + @Test + void fromArgs_whenMissingApiBaseUrl_thenThrowsException() { + String[] args = {"--api-spec", "spec.json"}; + + assertThatThrownBy(() -> McpServerConfig.fromArgs(args)) + .isInstanceOf(McpServerToolInitializeException.class) + .hasMessage(ErrorMessage.MISSING_API_BASE_URL); + } + + @Test + void fromArgs_whenMissingApiSpec_thenThrowsException() { + String[] args = {"--api-base-url", "https://api.example.com"}; + + assertThatThrownBy(() -> McpServerConfig.fromArgs(args)) + .isInstanceOf(McpServerToolInitializeException.class) + .hasMessage(ErrorMessage.MISSING_API_SPEC); + } + + @Test + void fromArgs_whenAuthTypeApiKeyButMissingKey_thenThrowsException() { + String[] args = { + "--api-base-url", "url", "--api-spec", "spec", + "--auth-type", "API_KEY", + "--auth-api-key-name", "X-API-KEY", + "--auth-api-key-in", "header" + }; + assertThatThrownBy(() -> McpServerConfig.fromArgs(args)) + .isInstanceOf(McpServerToolInitializeException.class) + .hasMessage("Missing API Key value for auth type API_KEY"); + } + + @Test + void fromArgs_whenAuthTypeApiKeyButMissingKeyName_thenThrowsException() { + String[] args = { + "--api-base-url", "url", "--api-spec", "spec", + "--auth-type", "API_KEY", + "--auth-api-key", "secretkey", + "--auth-api-key-in", "header" + }; + assertThatThrownBy(() -> McpServerConfig.fromArgs(args)) + .isInstanceOf(McpServerToolInitializeException.class) + .hasMessage("Missing API Key name (--auth-api-key-name) for auth type API_KEY"); + } + + @Test + void fromArgs_whenAuthTypeApiKeyButInvalidLocation_thenThrowsException() { + String[] args = { + "--api-base-url", "url", "--api-spec", "spec", + "--auth-type", "API_KEY", + "--auth-api-key", "secretkey", + "--auth-api-key-name", "X-API-KEY", + "--auth-api-key-in", "cookie" // Invalid location + }; + assertThatThrownBy(() -> McpServerConfig.fromArgs(args)) + .isInstanceOf(McpServerToolInitializeException.class) + .hasMessage("Invalid or missing API Key location (--auth-api-key-in). Must be 'header' or 'query'."); + } + + @Test + void fromArgs_whenAuthTypeBasicButMissingUsername_thenThrowsException() { + String[] args = { + "--api-base-url", "url", "--api-spec", "spec", + "--auth-type", "BASIC", + "--auth-password", "secretpass" + }; + assertThatThrownBy(() -> McpServerConfig.fromArgs(args)) + .isInstanceOf(McpServerToolInitializeException.class) + .hasMessage("Missing username for BASIC auth"); + } + + @Test + void fromArgs_whenAuthTypeBasicButMissingPassword_thenThrowsException() { + String[] args = { + "--api-base-url", "url", "--api-spec", "spec", + "--auth-type", "BASIC", + "--auth-username", "user" + }; + assertThatThrownBy(() -> McpServerConfig.fromArgs(args)) + .isInstanceOf(McpServerToolInitializeException.class) + .hasMessage("Missing password for BASIC auth"); + } + + @Test + void fromArgs_whenAuthTypeBearerButMissingToken_thenThrowsException() { + String[] args = { + "--api-base-url", "url", "--api-spec", "spec", + "--auth-type", "BEARER" + }; + assertThatThrownBy(() -> McpServerConfig.fromArgs(args)) + .isInstanceOf(McpServerToolInitializeException.class) + .hasMessage("Missing bearer token for BEARER auth"); + } + + @Test + void fromArgs_whenCustomHeadersAreInvalidJson_thenThrowsException() { + String[] args = { + "--api-base-url", "url", "--api-spec", "spec", + "--auth-type", "CUSTOM", + "--auth-custom-headers", "{not-valid-json}" + }; + assertThatThrownBy(() -> McpServerConfig.fromArgs(args)) + .isInstanceOf(McpServerToolInitializeException.class) + .hasMessageStartingWith("Invalid JSON format for --auth-custom-headers:"); + } + + @Test + void getTimeoutMs_whenValueIsInvalid_returnsDefaultValue() { + McpServerConfig config = McpServerConfig.builder() + .connectTimeout("invalid") + .responseTimeout("not-a-number") + .build(); + + assertThat(config.getConnectTimeoutMs()).isEqualTo(10_000L); + assertThat(config.getResponseTimeoutMs()).isEqualTo(30_000L); + } + + @Test + void getProxyPort_whenValueIsInvalid_returnsNull() throws McpServerToolInitializeException { + String[] args = { + "--api-base-url", "url", "--api-spec", "spec", + "--proxy-port", "not-an-integer" + }; + + McpServerConfig config = McpServerConfig.fromArgs(args); + assertThat(config.getProxyPort()).isNull(); + } + + @Test + void builder_whenSecretsAreSet_clonesTheCharArrays() { + char[] originalToken = {'t', 'o', 'k', 'e', 'n'}; + McpServerConfig config = McpServerConfig.builder() + .authToken(originalToken) + .build(); + + // Modify the original array after building + originalToken[0] = 'X'; + + // The config should hold the original, unmodified value + assertThat(config.getAuthToken()).isEqualTo(new char[]{'t', 'o', 'k', 'e', 'n'}); + } +} + diff --git a/src/openapi-mcp-server/src/test/resources/__files/companies-response.json b/src/openapi-mcp-server/src/test/resources/__files/companies-response.json new file mode 100644 index 00000000..9f343295 --- /dev/null +++ b/src/openapi-mcp-server/src/test/resources/__files/companies-response.json @@ -0,0 +1 @@ +[{"id":1,"name":"Acme Corp"},{"id":2,"name":"Globex Ltd"}] \ No newline at end of file diff --git a/src/openapi-mcp-server/src/test/resources/__files/company-1-response.json b/src/openapi-mcp-server/src/test/resources/__files/company-1-response.json new file mode 100644 index 00000000..17a49381 --- /dev/null +++ b/src/openapi-mcp-server/src/test/resources/__files/company-1-response.json @@ -0,0 +1 @@ +{"id":1,"name":"Acme Corp","industry":"Technology"} \ No newline at end of file diff --git a/src/openapi-mcp-server/src/test/resources/__files/company-department-swagger.json b/src/openapi-mcp-server/src/test/resources/__files/company-department-swagger.json new file mode 100644 index 00000000..8a5b10e6 --- /dev/null +++ b/src/openapi-mcp-server/src/test/resources/__files/company-department-swagger.json @@ -0,0 +1,232 @@ +{ + "openapi": "3.0.0", + "info": { + "title": "Company API", + "description": "A simple API to manage companies, departments, and users.", + "version": "1.0.0" + }, + "servers": [ + { + "url": "http://localhost:8080", + "description": "Mock server" + } + ], + "paths": { + "/rest/v1/companies": { + "get": { + "summary": "Get all companies", + "description": "Retrieves a list of all companies.", + "operationId": "getCompanies", + "responses": { + "200": { + "description": "A list of companies.", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanySummary" + } + } + } + } + } + } + }, + "post": { + "summary": "Create a new company", + "description": "Creates a new company and returns the created company object.", + "operationId": "createCompany", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanyCreateRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Company created successfully.", + "headers": { + "Location": { + "description": "The URL of the newly created company.", + "schema": { + "type": "string", + "example": "/rest/v1/companies/123" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Company" + } + } + } + }, + "400": { + "description": "Invalid request payload." + } + } + } + }, + "/rest/v1/companies/{companyId}": { + "get": { + "summary": "Get a company by ID", + "description": "Retrieves a single company by its ID.", + "operationId": "getCompanyById", + "parameters": [ + { + "name": "companyId", + "in": "path", + "required": true, + "description": "The ID of the company to retrieve.", + "schema": { + "type": "integer", + "example": 1 + } + } + ], + "responses": { + "200": { + "description": "The company object.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Company" + } + } + } + }, + "404": { + "description": "Company not found." + } + } + }, + "patch": { + "summary": "Update a company", + "description": "Partially updates an existing company. Only the fields provided in the request body will be updated.", + "operationId": "updateCompany", + "parameters": [ + { + "name": "companyId", + "in": "path", + "required": true, + "description": "The ID of the company to update.", + "schema": { + "type": "integer", + "example": 1 + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CompanyUpdateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Company updated successfully.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Company" + } + } + } + }, + "400": { + "description": "Invalid request payload." + }, + "404": { + "description": "Company not found." + } + } + } + } + }, + "components": { + "schemas": { + "CompanySummary": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "name": { + "type": "string", + "example": "Acme Corp" + } + } + }, + "Company": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 123 + }, + "name": { + "type": "string", + "example": "New Company" + }, + "industry": { + "type": "string", + "example": "Technology" + }, + "address": { + "type": "string", + "example": "123 Test Street" + } + } + }, + "CompanyCreateRequest": { + "type": "object", + "required": [ + "name", + "industry" + ], + "properties": { + "name": { + "type": "string", + "example": "New Company" + }, + "industry": { + "type": "string", + "example": "Finance" + }, + "address": { + "type": "string", + "example": "123 Test Street" + } + } + }, + "CompanyUpdateRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Updated Company Name" + }, + "industry": { + "type": "string", + "example": "Manufacturing" + }, + "address": { + "type": "string", + "example": "456 New Avenue" + } + } + } + } + } +} \ No newline at end of file diff --git a/src/openapi-mcp-server/src/test/resources/mappings/comapny-department-swagger.json b/src/openapi-mcp-server/src/test/resources/mappings/comapny-department-swagger.json new file mode 100644 index 00000000..c97763fe --- /dev/null +++ b/src/openapi-mcp-server/src/test/resources/mappings/comapny-department-swagger.json @@ -0,0 +1,13 @@ +{ + "request": { + "method": "GET", + "url": "/rest/v1/metadata-catalog/companies" + }, + "response": { + "status": 200, + "bodyFileName": "company-department-swagger.json", + "headers": { + "Content-Type": "application/json" + } + } +} diff --git a/src/openapi-mcp-server/src/test/resources/mappings/companies.json b/src/openapi-mcp-server/src/test/resources/mappings/companies.json new file mode 100644 index 00000000..6ee192a2 --- /dev/null +++ b/src/openapi-mcp-server/src/test/resources/mappings/companies.json @@ -0,0 +1,17 @@ +{ + "request": { + "method": "GET", + "url": "/rest/v1/companies", + "basicAuth": { + "username": "test-user", + "password": "test-password" + } + }, + "response": { + "status": 200, + "bodyFileName": "companies-response.json", + "headers": { + "Content-Type": "application/json" + } + } +} diff --git a/src/openapi-mcp-server/src/test/resources/mappings/company-by-id.json b/src/openapi-mcp-server/src/test/resources/mappings/company-by-id.json new file mode 100644 index 00000000..3d476e72 --- /dev/null +++ b/src/openapi-mcp-server/src/test/resources/mappings/company-by-id.json @@ -0,0 +1,18 @@ +{ + "request": { + "method": "GET", + "urlPattern": "/rest/v1/companies/1", + "headers": { + "Authorization": { + "equalTo": "Bearer test-token" + } + } + }, + "response": { + "status": 200, + "bodyFileName": "company-1-response.json", + "headers": { + "Content-Type": "application/json" + } + } +} diff --git a/src/openapi-mcp-server/src/test/resources/mappings/create-company.json b/src/openapi-mcp-server/src/test/resources/mappings/create-company.json new file mode 100644 index 00000000..60266e6a --- /dev/null +++ b/src/openapi-mcp-server/src/test/resources/mappings/create-company.json @@ -0,0 +1,30 @@ +{ + "request": { + "method": "POST", + "url": "/rest/v1/companies", + "headers": { + "X-API-KEY": { + "equalTo": "test-api-key" + }, + "Content-Type": { + "equalTo": "application/json" + } + }, + "bodyPatterns": [ + { + "matchesJsonPath": "$.name" + }, + { + "matchesJsonPath": "$.address" + } + ] + }, + "response": { + "status": 201, + "bodyFileName": "company-1-response.json", + "headers": { + "Content-Type": "application/json", + "Location": "/rest/v1/companies/1" + } + } +} diff --git a/src/openapi-mcp-server/src/test/resources/mappings/update-company.json b/src/openapi-mcp-server/src/test/resources/mappings/update-company.json new file mode 100644 index 00000000..a9b17613 --- /dev/null +++ b/src/openapi-mcp-server/src/test/resources/mappings/update-company.json @@ -0,0 +1,22 @@ +{ + "request": { + "method": "PATCH", + "url": "/rest/v1/companies/1", + "headers": { + "CUSTOM-HEADER": { + "equalTo": "test-custom-key" + }, + "Content-Type": { + "equalTo": "application/json" + } + } + }, + "response": { + "status": 201, + "bodyFileName": "company-1-response.json", + "headers": { + "Content-Type": "application/json", + "Location": "/rest/v1/companies/1" + } + } +} \ No newline at end of file diff --git a/src/openapi-mcp-server/src/test/resources/tools/listTool.json b/src/openapi-mcp-server/src/test/resources/tools/listTool.json new file mode 100644 index 00000000..dc114de3 --- /dev/null +++ b/src/openapi-mcp-server/src/test/resources/tools/listTool.json @@ -0,0 +1 @@ +[{"tool":{"name":"getCompanies","title":"Get all companies","description":"Get all companies","inputSchema":{"type":"object","additionalProperties":false},"outputSchema":{"type":"object","properties":{"response":{"type":"array","items":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":"string"}}}}},"additionalProperties":true},"_meta":{"httpMethod":"GET","path":"/rest/v1/companies"}},"call":null,"callHandler":{}},{"tool":{"name":"createCompany","title":"Create a new company","description":"Create a new company","inputSchema":{"type":"object","properties":{"name":{"type":"string"},"industry":{"type":"string"},"address":{"type":"string"}},"required":["industry","name"],"additionalProperties":false},"outputSchema":{"type":"object","additionalProperties":true},"_meta":{"httpMethod":"POST","path":"/rest/v1/companies"}},"call":null,"callHandler":{}},{"tool":{"name":"getCompanyById","title":"Get a company by ID","description":"Get a company by ID","inputSchema":{"type":"object","properties":{"companyId":{"type":"integer","description":"The ID of the company to retrieve."}},"required":["companyId"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"response":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":"string"},"industry":{"type":"string"},"address":{"type":"string"}}}},"additionalProperties":true},"_meta":{"httpMethod":"GET","path":"/rest/v1/companies/{companyId}","pathParams":{"companyId":{"name":"companyId","required":true,"description":"The ID of the company to retrieve.","type":"integer"}}}},"call":null,"callHandler":{}},{"tool":{"name":"updateCompany","title":"Update a company","description":"Update a company","inputSchema":{"type":"object","properties":{"companyId":{"type":"integer","description":"The ID of the company to update."},"name":{"type":"string"},"industry":{"type":"string"},"address":{"type":"string"}},"required":["companyId"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"response":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":"string"},"industry":{"type":"string"},"address":{"type":"string"}}}},"additionalProperties":true},"_meta":{"httpMethod":"PATCH","path":"/rest/v1/companies/{companyId}","pathParams":{"companyId":{"name":"companyId","required":true,"description":"The ID of the company to update.","type":"integer"}}}},"call":null,"callHandler":{}}] \ No newline at end of file From 7cdb689c361b967a66a23a7c3f7659a5374bd51d Mon Sep 17 00:00:00 2001 From: Joby Wilson Mathews Date: Wed, 3 Sep 2025 20:13:59 +0530 Subject: [PATCH 08/12] Added README.md --- src/openapi-mcp-server/README.md | 84 ++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 src/openapi-mcp-server/README.md diff --git a/src/openapi-mcp-server/README.md b/src/openapi-mcp-server/README.md new file mode 100644 index 00000000..6f8a634b --- /dev/null +++ b/src/openapi-mcp-server/README.md @@ -0,0 +1,84 @@ +# OpenAPI MCP Server + +## Overview + +This project is a server that dynamically generates MCP (Model Context Protocol) tools from OpenAPI specifications, enabling Large Language Models (LLMs) to interact seamlessly with APIs via the Model Context Protocol. +## Features + +* Dynamic Tool Generation: Automatically creates MCP tools from OpenAPI endpoints for LLM interaction. +* Transport Options: Supports stdio transport +* Flexible Configuration: Easily configure through environment variables or CLI arguments. +* OpenAPI & Swagger Support: Compatible with OpenAPI 3.x and Swagger specs in JSON or YAML. +* Authentication Support: Multiple methods including Basic, Bearer Token, API Key, and custom. + +## Prerequisites + +- **Java 21** + Make sure the JDK is installed and `JAVA_HOME` is set correctly. + +- **Maven 3.x** + Required for building the project and managing dependencies. + +- **Valid OpenAPI specifications** + JSON or YAML files describing the APIs you want to generate MCP tools for. + +- **Authentication configuration (optional)** + Environment variables or configuration files for API authentication (e.g., API Key, Bearer Token, Basic Auth, or custom methods). + +## Installation + +1. **Clone this repository** +2. **Navigate to the project directory** +3. **Build the project using Maven** +```bash +maven clean package -P release +``` +4. **Run the jar with arguments** +```bash +java -jar target/openapi-mcp-server-1.0.jar --api-spec https://api.example.com/openapi.json --api-base-url https://api.example.com +``` + +## MCP Server Configuration +Installation is dependent on the MCP Client being used, it usually consists of adding the MCP Server invocation in a json config file, for example with Claude UI on windows it looks like this: + +```json +{ + "mcpServers": { + "cpq-server": { + "command": "java", + "args": [ + "-jar", + "/Users/johnwick/Documents/Projects/mcp/src/openapi-mcp-server/target/openapi-mcp-server-1.0.jar", + "--api-spec", + "https://api.example.com/openapi.json", + "--api-base-url", + "https://api.example.com" + ], + "env": {} + } + } +} +``` + +## Environment Variables + +The server supports the following environment variables: + +| Environment Variable | Description | +|-----------------------------|-------------------------------------------------------| +| `API_BASE_URL` | Base URL of the API | +| `API_SPEC` | Path to the OpenAPI specification (JSON or YAML) | +| `AUTH_TYPE` | Authentication type (`BASIC`, `BEARER`, `API_KEY`, or custom) | +| `AUTH_TOKEN` | Token for Bearer authentication | +| `AUTH_USERNAME` | Username for Basic authentication | +| `AUTH_PASSWORD` | Password for Basic authentication | +| `AUTH_API_KEY` | API key value for `API_KEY` authentication | +| `API_API_KEY_NAME` | Name of the API key for header or query placement | +| `API_API_KEY_IN` | Location of API key (`header` or `query`) | +| `AUTH_CUSTOM_HEADERS` | JSON string representing custom authentication headers | +| `API_HTTP_CONNECT_TIMEOUT` | Connection timeout in milliseconds | +| `API_HTTP_RESPONSE_TIMEOUT` | Response timeout in milliseconds | +| `API_HTTP_VERSION` | HTTP version to use (`HTTP_1_1` or `HTTP_2`) | +| `API_HTTP_REDIRECT` | Redirect policy (`NORMAL`, `NEVER`, `ALWAYS`) | +| `API_HTTP_PROXY_HOST` | Hostname of HTTP proxy server | +| `API_HTTP_PROXY_PORT` | Port of HTTP proxy server | From 02e131ae75aa2b31300ff06bdad83f28339989c1 Mon Sep 17 00:00:00 2001 From: Joby Wilson Mathews Date: Thu, 4 Sep 2025 17:12:28 +0530 Subject: [PATCH 09/12] Code refactory Added README.md --- src/openapi-mcp-server/README.md | 120 +++++----- .../oracle/mcp/openapi/OpenApiMcpServer.java | 3 +- .../config/OpenApiMcpServerConfiguration.java | 15 +- .../mcp/openapi/constants/CommonConstant.java | 39 ++++ .../mcp/openapi/constants/ErrorMessage.java | 4 + .../openapi/fetcher/OpenApiSchemaFetcher.java | 53 +---- .../mcp/openapi/mapper/McpToolMapper.java | 3 +- .../mapper/impl/OpenApiToMcpToolMapper.java | 212 +++++++----------- .../mapper/impl/SwaggerToMcpToolMapper.java | 168 +++++++------- .../mcp/openapi/rest/RestApiAuthHandler.java | 72 ++++++ .../openapi/tool/OpenApiMcpToolExecutor.java | 23 +- .../tool/OpenApiMcpToolInitializer.java | 17 +- .../mcp/openapi/util/McpServerUtil.java | 38 ++++ .../mcp/openapi/OpenApiMcpServerTest.java | 109 ++++----- .../src/test/resources/tools/listTool.json | 2 +- 15 files changed, 469 insertions(+), 409 deletions(-) create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/CommonConstant.java create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiAuthHandler.java create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/util/McpServerUtil.java diff --git a/src/openapi-mcp-server/README.md b/src/openapi-mcp-server/README.md index 6f8a634b..e59bfd67 100644 --- a/src/openapi-mcp-server/README.md +++ b/src/openapi-mcp-server/README.md @@ -1,84 +1,82 @@ # OpenAPI MCP Server -## Overview +This server acts as a bridge 🌉, dynamically generating **Model Context Protocol (MCP)** tools from **OpenAPI specifications**. This allows Large Language Models (LLMs) to seamlessly interact with your APIs. -This project is a server that dynamically generates MCP (Model Context Protocol) tools from OpenAPI specifications, enabling Large Language Models (LLMs) to interact seamlessly with APIs via the Model Context Protocol. -## Features +--- +## ✨ Features -* Dynamic Tool Generation: Automatically creates MCP tools from OpenAPI endpoints for LLM interaction. -* Transport Options: Supports stdio transport -* Flexible Configuration: Easily configure through environment variables or CLI arguments. -* OpenAPI & Swagger Support: Compatible with OpenAPI 3.x and Swagger specs in JSON or YAML. -* Authentication Support: Multiple methods including Basic, Bearer Token, API Key, and custom. +* ⚡ **Dynamic Tool Generation**: Automatically creates MCP tools from any OpenAPI endpoint for LLM interaction. +* 📡 **Transport Options**: Natively supports `stdio` transport for communication. +* ⚙️ **Flexible Configuration**: Easily configure the server using command-line arguments or environment variables. +* 📚 **OpenAPI & Swagger Support**: Compatible with OpenAPI 3.x and Swagger specs in both JSON and YAML formats. +* 🔑 **Authentication Support**: Handles multiple authentication methods, including Basic, Bearer Token, API Key, and custom headers. -## Prerequisites +--- +## 🚀 Getting Started -- **Java 21** - Make sure the JDK is installed and `JAVA_HOME` is set correctly. +### Prerequisites -- **Maven 3.x** - Required for building the project and managing dependencies. +* **Java 21**: Make sure the JDK is installed and your `JAVA_HOME` environment variable is set correctly. +* **Maven 3.x**: Required for building the project and managing its dependencies. +* **Valid OpenAPI Specification**: You'll need a JSON or YAML file describing the API you want to connect to. -- **Valid OpenAPI specifications** - JSON or YAML files describing the APIs you want to generate MCP tools for. +### Installation & Build -- **Authentication configuration (optional)** - Environment variables or configuration files for API authentication (e.g., API Key, Bearer Token, Basic Auth, or custom methods). +1. **Clone the repository** and navigate to the project directory. +2. **Build the project** into a runnable JAR file using Maven. This is the only step needed before configuring your client. + ```bash + mvn clean package -P release + ``` -## Installation +--- +## 🔧 Configuration -1. **Clone this repository** -2. **Navigate to the project directory** -3. **Build the project using Maven** -```bash -maven clean package -P release -``` -4. **Run the jar with arguments** -```bash -java -jar target/openapi-mcp-server-1.0.jar --api-spec https://api.example.com/openapi.json --api-base-url https://api.example.com -``` +You can configure the server in two primary ways: command-line arguments or environment variables. This configuration is provided to the MCP client, which then uses it to launch the server. -## MCP Server Configuration -Installation is dependent on the MCP Client being used, it usually consists of adding the MCP Server invocation in a json config file, for example with Claude UI on windows it looks like this: +### Environment Variables + +The server supports the following environment variables. These can be set within the MCP client's configuration. + +| Environment Variable | Description | Example | +| :--- | :--- | :--- | +| `API_BASE_URL` | Base URL of the API. | `https://api.example.com/v1` | +| `API_SPEC` | Path or URL to the OpenAPI specification. | `/configs/openapi.yaml` | +| `AUTH_TYPE` | Authentication type (`BASIC`, `BEARER`, `API_KEY`). | `BEARER` | +| `AUTH_TOKEN` | Token for Bearer authentication. | `eyJhbGciOiJIUzI1NiIsInR5cCI6...` | +| `AUTH_USERNAME` | Username for Basic authentication. | `adminUser` | +| `AUTH_PASSWORD` | Password for Basic authentication. | `P@ssw0rd!` | +| `AUTH_API_KEY` | API key value for `API_KEY` authentication. | `12345-abcdef-67890` | +| `API_API_KEY_NAME`| Name of the API key parameter. | `X-API-KEY` | +| `API_API_KEY_IN` | Location of API key (`header` or `query`). | `header` | +| `AUTH_CUSTOM_HEADERS`| JSON string of custom authentication headers. | `{"X-Tenant-ID": "acme"}` | +| `API_HTTP_CONNECT_TIMEOUT`| Connection timeout in milliseconds. | `5000` | +| `API_HTTP_RESPONSE_TIMEOUT`| Response timeout in milliseconds. | `10000` | + +--- +## 🔌 Integrating with an MCP Client + +The MCP client launches this server as a short-lived process whenever API interaction is needed. Your client configuration must specify the command to execute the .jar file along with its arguments. +#### Example: Client JSON Configuration + +Here's how you might configure a client (like VS Code's Cline) to invoke this server, passing the required arguments. ```json { "mcpServers": { - "cpq-server": { + "my-api-server": { "command": "java", "args": [ "-jar", - "/Users/johnwick/Documents/Projects/mcp/src/openapi-mcp-server/target/openapi-mcp-server-1.0.jar", + "/path/to/your/project/target/openapi-mcp-server-1.0.jar", "--api-spec", - "https://api.example.com/openapi.json", - "--api-base-url", - "https://api.example.com" + "[https://api.example.com/openapi.json](https://api.example.com/openapi.json)", + "--api-base-url", + "[https://api.example.com](https://api.example.com)" ], - "env": {} + "env": { + "AUTH_TYPE": "BEARER", + "AUTH_TOKEN": "your-secret-token-here" + } } } -} -``` - -## Environment Variables - -The server supports the following environment variables: - -| Environment Variable | Description | -|-----------------------------|-------------------------------------------------------| -| `API_BASE_URL` | Base URL of the API | -| `API_SPEC` | Path to the OpenAPI specification (JSON or YAML) | -| `AUTH_TYPE` | Authentication type (`BASIC`, `BEARER`, `API_KEY`, or custom) | -| `AUTH_TOKEN` | Token for Bearer authentication | -| `AUTH_USERNAME` | Username for Basic authentication | -| `AUTH_PASSWORD` | Password for Basic authentication | -| `AUTH_API_KEY` | API key value for `API_KEY` authentication | -| `API_API_KEY_NAME` | Name of the API key for header or query placement | -| `API_API_KEY_IN` | Location of API key (`header` or `query`) | -| `AUTH_CUSTOM_HEADERS` | JSON string representing custom authentication headers | -| `API_HTTP_CONNECT_TIMEOUT` | Connection timeout in milliseconds | -| `API_HTTP_RESPONSE_TIMEOUT` | Response timeout in milliseconds | -| `API_HTTP_VERSION` | HTTP version to use (`HTTP_1_1` or `HTTP_2`) | -| `API_HTTP_REDIRECT` | Redirect policy (`NORMAL`, `NEVER`, `ALWAYS`) | -| `API_HTTP_PROXY_HOST` | Hostname of HTTP proxy server | -| `API_HTTP_PROXY_PORT` | Port of HTTP proxy server | +} \ No newline at end of file diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java index 29feda76..2b0921dd 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java @@ -29,7 +29,8 @@ import io.modelcontextprotocol.server.McpSyncServer; import org.springframework.context.ConfigurableApplicationContext; -import java.util.*; +import java.util.List; + /** * Entry point for the OpenAPI MCP server. diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java index 42f047c7..2dab3bb9 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java @@ -10,6 +10,7 @@ import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.oracle.mcp.openapi.cache.McpServerCacheService; import com.oracle.mcp.openapi.fetcher.OpenApiSchemaFetcher; +import com.oracle.mcp.openapi.rest.RestApiAuthHandler; import com.oracle.mcp.openapi.rest.RestApiExecutionService; import com.oracle.mcp.openapi.tool.OpenApiMcpToolExecutor; import com.oracle.mcp.openapi.tool.OpenApiMcpToolInitializer; @@ -95,8 +96,14 @@ public OpenApiMcpToolInitializer openApiToMcpToolConverter(McpServerCacheService */ @Bean public OpenApiSchemaFetcher openApiDefinitionFetcher(@Qualifier("jsonMapper") ObjectMapper jsonMapper, - @Qualifier("yamlMapper") ObjectMapper yamlMapper) { - return new OpenApiSchemaFetcher(jsonMapper, yamlMapper); + @Qualifier("yamlMapper") ObjectMapper yamlMapper, + RestApiAuthHandler restApiAuthHandler) { + return new OpenApiSchemaFetcher(jsonMapper, yamlMapper, restApiAuthHandler); + } + + @Bean + public RestApiAuthHandler restApiAuthHandler(){ + return new RestApiAuthHandler(); } /** @@ -109,7 +116,7 @@ public OpenApiSchemaFetcher openApiDefinitionFetcher(@Qualifier("jsonMapper") Ob * @return A new {@code OpenApiMcpToolExecutor} instance. */ @Bean - public OpenApiMcpToolExecutor openApiMcpToolExecutor(McpServerCacheService mcpServerCacheService, RestApiExecutionService restApiExecutionService, @Qualifier("jsonMapper") ObjectMapper jsonMapper) { - return new OpenApiMcpToolExecutor(mcpServerCacheService, restApiExecutionService, jsonMapper); + public OpenApiMcpToolExecutor openApiMcpToolExecutor(McpServerCacheService mcpServerCacheService, RestApiExecutionService restApiExecutionService, @Qualifier("jsonMapper") ObjectMapper jsonMapper,RestApiAuthHandler restApiAuthHandler) { + return new OpenApiMcpToolExecutor(mcpServerCacheService, restApiExecutionService, jsonMapper,restApiAuthHandler); } } \ No newline at end of file diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/CommonConstant.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/CommonConstant.java new file mode 100644 index 00000000..e7344089 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/CommonConstant.java @@ -0,0 +1,39 @@ +package com.oracle.mcp.openapi.constants; + +public interface CommonConstant { + + String OBJECT = "object"; + + String ADDITIONAL_PROPERTIES ="additionalProperties"; + + String OPEN_API = "openapi"; + String SWAGGER = "swagger"; + + // Meta keys + String HTTP_METHOD = "httpMethod"; + String PATH = "path"; + String TAGS = "tags"; + String SECURITY = "security"; + String PATH_PARAMS = "pathParams"; + String QUERY_PARAMS = "queryParams"; + + // Schema keys + String TYPE = "type"; + String DESCRIPTION = "description"; + String FORMAT = "format"; + String ENUM = "enum"; + String PROPERTIES = "properties"; + String REQUIRED = "required"; + String ITEMS = "items"; + String NAME = "name"; + String ARRAY = "array" ; + String QUERY = "query"; + + + + //String constants + String UNDER_SCORE = "_"; + + //REST constants + String APPLICATION_JSON = "application/json"; +} diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/ErrorMessage.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/ErrorMessage.java index 617fddc2..acd23037 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/ErrorMessage.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/ErrorMessage.java @@ -21,4 +21,8 @@ public interface ErrorMessage { String MISSING_API_SPEC = "API specification not provided. Please pass --api-spec or set the API_SPEC environment variable."; String MISSING_API_BASE_URL = "API base url not provided. Please pass --api-base-url or set the API_BASE_URL environment variable."; + + String MISSING_PATH_IN_SPEC = "'paths' object not found in the specification."; + + String INVALID_SPEC_DEFINITION = "Unsupported API definition: missing 'openapi' or 'swagger' field"; } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java index ada77665..1cbb8065 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java @@ -12,6 +12,7 @@ import com.oracle.mcp.openapi.model.McpServerConfig; import com.oracle.mcp.openapi.enums.OpenApiSchemaSourceType; import com.oracle.mcp.openapi.enums.OpenApiSchemaType; +import com.oracle.mcp.openapi.rest.RestApiAuthHandler; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -19,14 +20,11 @@ import java.io.InputStream; import java.net.HttpURLConnection; import java.net.URL; -import java.nio.ByteBuffer; -import java.nio.CharBuffer; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.Arrays; -import java.util.Base64; +import java.util.Map; /** * Utility class responsible for fetching and parsing OpenAPI schema definitions. @@ -48,6 +46,7 @@ public class OpenApiSchemaFetcher { private final ObjectMapper jsonMapper; private final ObjectMapper yamlMapper; + private final RestApiAuthHandler restApiAuthHandler; /** * Creates a new {@code OpenApiSchemaFetcher}. @@ -55,9 +54,10 @@ public class OpenApiSchemaFetcher { * @param jsonMapper {@link ObjectMapper} configured for JSON parsing. * @param yamlMapper {@link ObjectMapper} configured for YAML parsing. */ - public OpenApiSchemaFetcher(ObjectMapper jsonMapper, ObjectMapper yamlMapper) { + public OpenApiSchemaFetcher(ObjectMapper jsonMapper, ObjectMapper yamlMapper, RestApiAuthHandler restApiAuthHandler) { this.jsonMapper = jsonMapper; this.yamlMapper = yamlMapper; + this.restApiAuthHandler = restApiAuthHandler; } /** @@ -129,46 +129,9 @@ private String downloadContent(McpServerConfig mcpServerConfig) throws Exception * @param mcpServerConfig configuration containing authentication details. */ private void applyAuth(HttpURLConnection conn, McpServerConfig mcpServerConfig) { - OpenApiSchemaAuthType authType = mcpServerConfig.getAuthType(); - if (authType != OpenApiSchemaAuthType.BASIC) { - return; - } - - String username = mcpServerConfig.getAuthUsername(); - char[] passwordChars = mcpServerConfig.getAuthPassword(); - - if (username == null || passwordChars == null) { - System.err.println("Username or password is not configured for Basic Auth."); - return; - } - - byte[] credentialsBytes = null; - byte[] passwordBytes = null; - - try { - byte[] usernameBytes = username.getBytes(StandardCharsets.UTF_8); - byte[] separator = {':'}; - - ByteBuffer passwordByteBuffer = StandardCharsets.UTF_8.encode(CharBuffer.wrap(passwordChars)); - passwordBytes = new byte[passwordByteBuffer.remaining()]; - passwordByteBuffer.get(passwordBytes); - - credentialsBytes = new byte[usernameBytes.length + separator.length + passwordBytes.length]; - System.arraycopy(usernameBytes, 0, credentialsBytes, 0, usernameBytes.length); - System.arraycopy(separator, 0, credentialsBytes, usernameBytes.length, separator.length); - System.arraycopy(passwordBytes, 0, credentialsBytes, usernameBytes.length + separator.length, passwordBytes.length); - - String encoded = Base64.getEncoder().encodeToString(credentialsBytes); - conn.setRequestProperty("Authorization", "Basic " + encoded); - - } finally { - Arrays.fill(passwordChars, '0'); - if (passwordBytes != null) { - Arrays.fill(passwordBytes, (byte) 0); - } - if (credentialsBytes != null) { - Arrays.fill(credentialsBytes, (byte) 0); - } + Map headers = restApiAuthHandler.extractAuthHeaders(mcpServerConfig); + for (Map.Entry entry : headers.entrySet()) { + conn.setRequestProperty(entry.getKey(), entry.getValue()); } } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/McpToolMapper.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/McpToolMapper.java index f59b4a65..6c567b30 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/McpToolMapper.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/McpToolMapper.java @@ -7,6 +7,7 @@ package com.oracle.mcp.openapi.mapper; import com.fasterxml.jackson.databind.JsonNode; +import com.oracle.mcp.openapi.exception.McpServerToolInitializeException; import io.modelcontextprotocol.spec.McpSchema; import java.util.List; @@ -31,5 +32,5 @@ public interface McpToolMapper { * @return a list of {@link McpSchema.Tool} objects derived from the given API specification; * never {@code null}, but may be empty if no tools are found. */ - List convert(JsonNode apiSpec); + List convert(JsonNode apiSpec) throws McpServerToolInitializeException; } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapper.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapper.java index c90aec50..2a435c6d 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapper.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapper.java @@ -8,7 +8,11 @@ import com.fasterxml.jackson.databind.JsonNode; import com.oracle.mcp.openapi.cache.McpServerCacheService; +import com.oracle.mcp.openapi.constants.CommonConstant; +import com.oracle.mcp.openapi.constants.ErrorMessage; +import com.oracle.mcp.openapi.exception.McpServerToolInitializeException; import com.oracle.mcp.openapi.mapper.McpToolMapper; +import com.oracle.mcp.openapi.util.McpServerUtil; import io.modelcontextprotocol.spec.McpSchema; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.Operation; @@ -16,13 +20,19 @@ 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.responses.ApiResponse; import io.swagger.v3.parser.OpenAPIV3Parser; import io.swagger.v3.parser.core.models.ParseOptions; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Set; +import java.util.Map; + /** * Implementation of {@link McpToolMapper} that converts an OpenAPI specification @@ -37,7 +47,6 @@ */ public class OpenApiToMcpToolMapper implements McpToolMapper { - private final Set usedNames = new HashSet<>(); private final McpServerCacheService mcpServerCacheService; private static final Logger LOGGER = LoggerFactory.getLogger(OpenApiToMcpToolMapper.class); @@ -46,12 +55,13 @@ public OpenApiToMcpToolMapper(McpServerCacheService mcpServerCacheService) { } @Override - public List convert(JsonNode openApiJson) { - LOGGER.debug("Parsing OpenAPI JsonNode to OpenAPI object..."); + public List convert(JsonNode openApiJson) throws McpServerToolInitializeException { + LOGGER.debug("Parsing OpenAPI schema to OpenAPI object."); OpenAPI openAPI = parseOpenApi(openApiJson); - + LOGGER.debug("Successfully parsed OpenAPI schema"); if (openAPI.getPaths() == null || openAPI.getPaths().isEmpty()) { - throw new IllegalArgumentException("'paths' object not found in the specification."); + LOGGER.error("There is not paths defined in schema "); + throw new McpServerToolInitializeException(ErrorMessage.MISSING_PATH_IN_SPEC); } List mcpTools = processPaths(openAPI); @@ -62,36 +72,41 @@ public List convert(JsonNode openApiJson) { private List processPaths(OpenAPI openAPI) { List mcpTools = new ArrayList<>(); - + Set toolNames = new HashSet<>(); for (Map.Entry pathEntry : openAPI.getPaths().entrySet()) { String path = pathEntry.getKey(); + LOGGER.debug("Parsing Path: {}", path); PathItem pathItem = pathEntry.getValue(); - if (pathItem == null) continue; + if (pathItem == null){ + continue; + } - processOperationsForPath(path, pathItem, mcpTools); + processOperationsForPath(path, pathItem, mcpTools,toolNames); } return mcpTools; } - private void processOperationsForPath(String path, PathItem pathItem, List mcpTools) { + private void processOperationsForPath(String path, PathItem pathItem, List mcpTools,Set toolNames) { for (Map.Entry methodEntry : pathItem.readOperationsMap().entrySet()) { PathItem.HttpMethod method = methodEntry.getKey(); Operation operation = methodEntry.getValue(); - if (operation == null) continue; + if (operation == null){ + continue; + } - McpSchema.Tool tool = buildToolFromOperation(path, method, operation); + McpSchema.Tool tool = buildToolFromOperation(path, method, operation,toolNames); if (tool != null) { mcpTools.add(tool); } } } - private McpSchema.Tool buildToolFromOperation(String path, PathItem.HttpMethod method, Operation operation) { + private McpSchema.Tool buildToolFromOperation(String path, PathItem.HttpMethod method, Operation operation, Set toolNames) { String rawOperationId = (operation.getOperationId() != null && !operation.getOperationId().isEmpty()) ? operation.getOperationId() - : toCamelCase(method.name() + " " + path); + : McpServerUtil.toCamelCase(method.name() + " " + path); - String toolName = makeUniqueName(rawOperationId); + String toolName = makeUniqueName(toolNames,rawOperationId); LOGGER.debug("--- Parsing Operation: {} {} (ID: {}) ---", method.name().toUpperCase(), path, toolName); String toolTitle = (operation.getSummary() != null && !operation.getSummary().isEmpty()) @@ -109,13 +124,12 @@ private McpSchema.Tool buildToolFromOperation(String path, PathItem.HttpMethod m extractRequestBody(operation, properties, requiredParams); McpSchema.JsonSchema inputSchema = new McpSchema.JsonSchema( - "object", + CommonConstant.OBJECT, properties.isEmpty() ? null : properties, requiredParams.isEmpty() ? null : requiredParams, false, null, null ); - Map outputSchema = extractOutputSchema(operation); - outputSchema.put("additionalProperties", true); + Map outputSchema = getOutputSchema(); Map meta = buildMeta(method, path, operation, pathParams, queryParams); @@ -129,24 +143,30 @@ private McpSchema.Tool buildToolFromOperation(String path, PathItem.HttpMethod m .build(); } + private Map getOutputSchema() { + Map outputSchema = new HashMap<>(); + outputSchema.put(CommonConstant.ADDITIONAL_PROPERTIES, true); + return outputSchema; + } + private Map buildMeta(PathItem.HttpMethod method, String path, Operation operation, Map> pathParams, Map> queryParams) { Map meta = new LinkedHashMap<>(); - meta.put("httpMethod", method.name()); - meta.put("path", path); + meta.put(CommonConstant.HTTP_METHOD, method.name()); + meta.put(CommonConstant.PATH, path); if (operation.getTags() != null) { - meta.put("tags", operation.getTags()); + meta.put(CommonConstant.TAGS, operation.getTags()); } if (operation.getSecurity() != null) { - meta.put("security", operation.getSecurity()); + meta.put(CommonConstant.SECURITY, operation.getSecurity()); } if (!pathParams.isEmpty()) { - meta.put("pathParams", pathParams); + meta.put(CommonConstant.PATH_PARAMS, pathParams); } if (!queryParams.isEmpty()) { - meta.put("queryParams", queryParams); + meta.put(CommonConstant.QUERY_PARAMS, queryParams); } return meta; } @@ -158,19 +178,21 @@ private void extractPathAndQueryParams(Operation operation, List requiredParams) { if (operation.getParameters() != null) { for (Parameter param : operation.getParameters()) { - if (param.getName() == null || param.getSchema() == null) continue; + if (param.getName() == null || param.getSchema() == null){ + continue; + } Map paramMeta = parameterMetaMap(param); - if ("path".equalsIgnoreCase(param.getIn())) { + if (CommonConstant.PATH.equalsIgnoreCase(param.getIn())) { pathParams.put(param.getName(), paramMeta); - } else if ("query".equalsIgnoreCase(param.getIn())) { + } else if (CommonConstant.QUERY.equalsIgnoreCase(param.getIn())) { queryParams.put(param.getName(), paramMeta); } - if ("path".equalsIgnoreCase(param.getIn()) || "query".equalsIgnoreCase(param.getIn())) { + if (CommonConstant.PATH.equalsIgnoreCase(param.getIn()) || CommonConstant.QUERY.equalsIgnoreCase(param.getIn())) { Map paramSchema = extractInputSchema(param.getSchema()); if (param.getDescription() != null && !param.getDescription().isEmpty()) { - paramSchema.put("description", param.getDescription()); + paramSchema.put(CommonConstant.DESCRIPTION, param.getDescription()); } properties.put(param.getName(), paramSchema); if (Boolean.TRUE.equals(param.getRequired())) { @@ -206,13 +228,13 @@ private OpenAPI parseOpenApi(JsonNode jsonNode) { private void extractRequestBody(Operation operation, Map properties, List requiredParams) { if (operation.getRequestBody() != null && operation.getRequestBody().getContent() != null) { - MediaType media = operation.getRequestBody().getContent().get("application/json"); + MediaType media = operation.getRequestBody().getContent().get(CommonConstant.APPLICATION_JSON); if (media != null && media.getSchema() != null) { Schema bodySchema = media.getSchema(); - if ("object".equals(bodySchema.getType()) && bodySchema.getProperties() != null) { - bodySchema.getProperties().forEach((name, schema) -> { - properties.put(name, extractInputSchema((Schema) schema)); - }); + if (CommonConstant.OBJECT.equals(bodySchema.getType()) && bodySchema.getProperties() != null) { + bodySchema.getProperties().forEach((name, schema) -> + properties.put(name, extractInputSchema((Schema) schema)) + ); if (bodySchema.getRequired() != null) { requiredParams.addAll(bodySchema.getRequired()); } @@ -228,128 +250,58 @@ private Map extractInputSchema(Schema openApiSchema) { Map jsonSchema = new LinkedHashMap<>(); - if (openApiSchema.getType() != null) jsonSchema.put("type", openApiSchema.getType()); - if (openApiSchema.getDescription() != null) jsonSchema.put("description", openApiSchema.getDescription()); - if (openApiSchema.getFormat() != null) jsonSchema.put("format", openApiSchema.getFormat()); - if (openApiSchema.getEnum() != null) jsonSchema.put("enum", openApiSchema.getEnum()); + if (openApiSchema.getType() != null){ + jsonSchema.put(CommonConstant.TYPE, openApiSchema.getType()); + } + if (openApiSchema.getDescription() != null){ + jsonSchema.put(CommonConstant.DESCRIPTION, openApiSchema.getDescription()); + } + if (openApiSchema.getFormat() != null){ + jsonSchema.put(CommonConstant.FORMAT, openApiSchema.getFormat()); + } + if (openApiSchema.getEnum() != null){ + jsonSchema.put(CommonConstant.ENUM, openApiSchema.getEnum()); + } - if ("object".equals(openApiSchema.getType())) { + if (CommonConstant.OBJECT.equals(openApiSchema.getType())) { if (openApiSchema.getProperties() != null) { Map nestedProperties = new LinkedHashMap<>(); openApiSchema.getProperties().forEach((key, value) -> nestedProperties.put(key, extractInputSchema((Schema) value))); - jsonSchema.put("properties", nestedProperties); + jsonSchema.put(CommonConstant.PROPERTIES, nestedProperties); } if (openApiSchema.getRequired() != null) { - jsonSchema.put("required", openApiSchema.getRequired()); + jsonSchema.put(CommonConstant.REQUIRED, openApiSchema.getRequired()); } } - if ("array".equals(openApiSchema.getType())) { + if (CommonConstant.ARRAY.equals(openApiSchema.getType())) { if (openApiSchema.getItems() != null) { - jsonSchema.put("items", extractInputSchema(openApiSchema.getItems())); + jsonSchema.put(CommonConstant.ITEMS, extractInputSchema(openApiSchema.getItems())); } } return jsonSchema; } - private Map extractOutputSchema(Operation operation) { - // This is the parent object that the tool schema requires. - // It will ALWAYS have "type": "object". - Map toolOutputSchema = new LinkedHashMap<>(); - toolOutputSchema.put("type", "object"); - - // Find the actual response schema from the OpenAPI spec - if (operation.getResponses() == null || operation.getResponses().isEmpty()) { - return toolOutputSchema; // Return empty object schema if no responses - } - ApiResponse response = operation.getResponses().getOrDefault("200", operation.getResponses().get("default")); - if (response == null || response.getContent() == null || !response.getContent().containsKey("application/json")) { - return toolOutputSchema; // Return empty object schema - } - Schema apiResponseSchema = response.getContent().get("application/json").getSchema(); - if (apiResponseSchema == null) { - return toolOutputSchema; // Return empty object schema - } - - // Recursively convert the OpenAPI response schema into a JSON schema map - Map convertedApiResponseSchema = extractSchemaRecursive(apiResponseSchema); - - // Create the 'properties' map for our parent object - Map properties = new LinkedHashMap<>(); - - // Put the actual API response schema inside the properties map. - // We'll use the key "response" to hold it. - properties.put("response", convertedApiResponseSchema); - - toolOutputSchema.put("properties", properties); - - return toolOutputSchema; - } - - private Map extractSchemaRecursive(Schema schema) { - if (schema == null) { - return Collections.emptyMap(); - } - - Map jsonSchema = new LinkedHashMap<>(); - - if (schema.getType() != null) jsonSchema.put("type", schema.getType()); - if (schema.getDescription() != null) jsonSchema.put("description", schema.getDescription()); - - if ("object".equals(schema.getType()) && schema.getProperties() != null) { - Map properties = new LinkedHashMap<>(); - for (Map.Entry entry : schema.getProperties().entrySet()) { - properties.put(entry.getKey(), extractSchemaRecursive(entry.getValue())); - } - jsonSchema.put("properties", properties); - } - - if ("array".equals(schema.getType()) && schema.getItems() != null) { - jsonSchema.put("items", extractSchemaRecursive(schema.getItems())); - } - - if (schema.getRequired() != null) { - jsonSchema.put("required", schema.getRequired()); - } - - return jsonSchema; - } - private Map parameterMetaMap(Parameter p) { Map paramMeta = new LinkedHashMap<>(); - paramMeta.put("name", p.getName()); - paramMeta.put("required", Boolean.TRUE.equals(p.getRequired())); + paramMeta.put(CommonConstant.NAME, p.getName()); + paramMeta.put(CommonConstant.REQUIRED, Boolean.TRUE.equals(p.getRequired())); if (p.getDescription() != null) { - paramMeta.put("description", p.getDescription()); + paramMeta.put(CommonConstant.DESCRIPTION, p.getDescription()); } if (p.getSchema() != null && p.getSchema().getType() != null) { - paramMeta.put("type", p.getSchema().getType()); + paramMeta.put(CommonConstant.TYPE, p.getSchema().getType()); } return paramMeta; } - private String makeUniqueName(String base) { + private String makeUniqueName(Set toolNames,String base) { String name = base; int counter = 1; - while (usedNames.contains(name)) { - name = base + "_" + counter++; + while (toolNames.contains(name)) { + name = base + CommonConstant.UNDER_SCORE + counter++; } - usedNames.add(name); + toolNames.add(name); return name; } - - private static String toCamelCase(String input) { - String[] parts = input.split("[^a-zA-Z0-9]+"); - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < parts.length; i++) { - if (parts[i].isEmpty()) continue; - String word = parts[i].toLowerCase(); - if (i == 0) { - sb.append(word); - } else { - sb.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1)); - } - } - return sb.toString(); - } } \ No newline at end of file diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java index b4f4827a..cb40bdf5 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java @@ -8,9 +8,18 @@ import com.fasterxml.jackson.databind.JsonNode; import com.oracle.mcp.openapi.cache.McpServerCacheService; +import com.oracle.mcp.openapi.constants.CommonConstant; import com.oracle.mcp.openapi.mapper.McpToolMapper; +import com.oracle.mcp.openapi.util.McpServerUtil; import io.modelcontextprotocol.spec.McpSchema; -import io.swagger.models.*; +import io.swagger.models.ArrayModel; +import io.swagger.models.HttpMethod; +import io.swagger.models.Model; +import io.swagger.models.ModelImpl; +import io.swagger.models.Operation; +import io.swagger.models.Path; +import io.swagger.models.RefModel; +import io.swagger.models.Swagger; import io.swagger.models.parameters.BodyParameter; import io.swagger.models.parameters.Parameter; import io.swagger.models.parameters.QueryParameter; @@ -23,7 +32,14 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.*; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; + /** * Implementation of {@link McpToolMapper} that converts Swagger 2.0 specifications @@ -38,7 +54,6 @@ */ public class SwaggerToMcpToolMapper implements McpToolMapper { - private final Set usedNames = new HashSet<>(); private final McpServerCacheService mcpServerCacheService; private static final Logger LOGGER = LoggerFactory.getLogger(SwaggerToMcpToolMapper.class); private Swagger swaggerSpec; // Stores the full spec to resolve $ref definitions @@ -83,14 +98,17 @@ public List convert(JsonNode swaggerJson) { */ private List processPaths(Swagger swagger) { List mcpTools = new ArrayList<>(); + Set toolNames = new HashSet<>(); if (swagger.getPaths() == null) return mcpTools; for (Map.Entry pathEntry : swagger.getPaths().entrySet()) { String path = pathEntry.getKey(); Path pathItem = pathEntry.getValue(); - if (pathItem == null) continue; + if (pathItem == null){ + continue; + } - processOperationsForPath(path, pathItem, mcpTools); + processOperationsForPath(path, pathItem, mcpTools,toolNames); } return mcpTools; } @@ -102,12 +120,14 @@ private List processPaths(Swagger swagger) { * @param pathItem the Swagger path item containing operations. * @param mcpTools the list to which new tools will be added. */ - private void processOperationsForPath(String path, Path pathItem, List mcpTools) { + private void processOperationsForPath(String path, Path pathItem, List mcpTools,Set toolNames) { Map operations = pathItem.getOperationMap(); - if (operations == null) return; + if (operations == null){ + return; + } for (Map.Entry methodEntry : operations.entrySet()) { - McpSchema.Tool tool = buildToolFromOperation(path, methodEntry.getKey(), methodEntry.getValue()); + McpSchema.Tool tool = buildToolFromOperation(path, methodEntry.getKey(), methodEntry.getValue(),toolNames); if (tool != null) { mcpTools.add(tool); } @@ -122,12 +142,12 @@ private void processOperationsForPath(String path, Path pathItem, List toolNames) { String rawOperationId = (operation.getOperationId() != null && !operation.getOperationId().isEmpty()) ? operation.getOperationId() - : toCamelCase(method.name() + " " + path); + : McpServerUtil.toCamelCase(method.name() + " " + path); - String toolName = makeUniqueName(rawOperationId); + String toolName = makeUniqueName(toolNames,rawOperationId); LOGGER.debug("--- Parsing Operation: {} {} (ID: {}) ---", method.name(), path, toolName); String toolTitle = operation.getSummary() != null ? operation.getSummary() : toolName; @@ -142,13 +162,13 @@ private McpSchema.Tool buildToolFromOperation(String path, HttpMethod method, Op extractRequestBody(operation, properties, requiredParams); McpSchema.JsonSchema inputSchema = new McpSchema.JsonSchema( - "object", + CommonConstant.OBJECT, properties.isEmpty() ? null : properties, requiredParams.isEmpty() ? null : requiredParams, false, null, null ); - Map outputSchema = extractOutputSchema(operation); + Map outputSchema = getOutputSchema(); Map meta = buildMeta(method, path, operation, pathParams, queryParams); return McpSchema.Tool.builder() @@ -161,6 +181,12 @@ private McpSchema.Tool buildToolFromOperation(String path, HttpMethod method, Op .build(); } + private Map getOutputSchema() { + Map outputSchema = new HashMap<>(); + outputSchema.put(CommonConstant.ADDITIONAL_PROPERTIES, true); + return outputSchema; + } + /** * Extracts path and query parameters from a Swagger operation and adds them to the input schema. */ @@ -169,19 +195,21 @@ private void extractPathAndQueryParams(Operation operation, Map> queryParams, Map properties, List requiredParams) { - if (operation.getParameters() == null) return; + if (operation.getParameters() == null){ + return; + } for (Parameter param : operation.getParameters()) { if (param instanceof PathParameter || param instanceof QueryParameter) { Map paramSchema = new LinkedHashMap<>(); - paramSchema.put("description", param.getDescription()); + paramSchema.put(CommonConstant.DESCRIPTION, param.getDescription()); if (param instanceof PathParameter) { - paramSchema.put("type", ((PathParameter) param).getType()); - pathParams.put(param.getName(), Map.of("name", param.getName(), "required", param.getRequired())); + paramSchema.put(CommonConstant.TYPE, ((PathParameter) param).getType()); + pathParams.put(param.getName(), Map.of(CommonConstant.NAME, param.getName(), CommonConstant.REQUIRED, param.getRequired())); } else { - paramSchema.put("type", ((QueryParameter) param).getType()); - queryParams.put(param.getName(), Map.of("name", param.getName(), "required", param.getRequired())); + paramSchema.put(CommonConstant.TYPE, ((QueryParameter) param).getType()); + queryParams.put(param.getName(), Map.of(CommonConstant.NAME, param.getName(), CommonConstant.REQUIRED, param.getRequired())); } properties.put(param.getName(), paramSchema); @@ -196,7 +224,9 @@ private void extractPathAndQueryParams(Operation operation, * Extracts request body schema (if present) from a Swagger operation. */ private void extractRequestBody(Operation operation, Map properties, List requiredParams) { - if (operation.getParameters() == null) return; + if (operation.getParameters() == null){ + return; + } operation.getParameters().stream() .filter(p -> p instanceof BodyParameter) @@ -225,19 +255,19 @@ private Map extractModelSchema(Model model) { } Map schema = new LinkedHashMap<>(); - if (model instanceof ModelImpl && ((ModelImpl) model).getProperties() != null) { + if (model instanceof ModelImpl && model.getProperties() != null) { Map props = new LinkedHashMap<>(); - ((ModelImpl) model).getProperties().forEach((key, prop) -> { - props.put(key, extractPropertySchema(prop)); - }); - schema.put("type", "object"); - schema.put("properties", props); + model.getProperties().forEach((key, prop) -> + props.put(key, extractPropertySchema(prop)) + ); + schema.put(CommonConstant.TYPE, CommonConstant.OBJECT); + schema.put(CommonConstant.PROPERTIES, props); if (((ModelImpl) model).getRequired() != null) { - schema.put("required", ((ModelImpl) model).getRequired()); + schema.put(CommonConstant.REQUIRED, ((ModelImpl) model).getRequired()); } } else if (model instanceof ArrayModel) { - schema.put("type", "array"); - schema.put("items", extractPropertySchema(((ArrayModel) model).getItems())); + schema.put(CommonConstant.TYPE, CommonConstant.ARRAY); + schema.put(CommonConstant.ITEMS, extractPropertySchema(((ArrayModel) model).getItems())); } return schema; } @@ -252,62 +282,38 @@ private Map extractPropertySchema(Property property) { if (definition != null) { return extractModelSchema(definition); } else { - return Map.of("type", "object", "description", "Unresolved reference: " + simpleRef); + return Map.of("type", CommonConstant.OBJECT, CommonConstant.DESCRIPTION, "Unresolved reference: " + simpleRef); } } Map schema = new LinkedHashMap<>(); - schema.put("type", property.getType()); - schema.put("description", property.getDescription()); + schema.put(CommonConstant.TYPE, property.getType()); + schema.put(CommonConstant.DESCRIPTION, property.getDescription()); if (property instanceof ObjectProperty) { Map props = new LinkedHashMap<>(); - ((ObjectProperty) property).getProperties().forEach((key, prop) -> { - props.put(key, extractPropertySchema(prop)); - }); - schema.put("properties", props); + ((ObjectProperty) property).getProperties().forEach((key, prop) -> + props.put(key, extractPropertySchema(prop)) + ); + schema.put(CommonConstant.PROPERTIES, props); } else if (property instanceof ArrayProperty) { - schema.put("items", extractPropertySchema(((ArrayProperty) property).getItems())); + schema.put(CommonConstant.ITEMS, extractPropertySchema(((ArrayProperty) property).getItems())); } return schema; } - /** - * Extracts the output schema for an operation (based on its 200/default response). - */ - private Map extractOutputSchema(Operation operation) { - Map toolOutputSchema = new LinkedHashMap<>(); - toolOutputSchema.put("type", "object"); - - if (operation.getResponses() == null || operation.getResponses().isEmpty()) { - return toolOutputSchema; - } - - Response response = operation.getResponses().getOrDefault("200", operation.getResponses().get("default")); - if (response == null || response.getSchema() == null) { - return toolOutputSchema; - } - - Map apiResponseSchema = extractPropertySchema(response.getSchema()); - Map properties = new LinkedHashMap<>(); - properties.put("response", apiResponseSchema); - toolOutputSchema.put("properties", properties); - - return toolOutputSchema; - } - /** * Builds metadata for an MCP tool, including HTTP method, path, tags, security, and parameter maps. */ private Map buildMeta(HttpMethod method, String path, Operation operation, Map> pathParams, Map> queryParams) { Map meta = new LinkedHashMap<>(); - meta.put("httpMethod", method.name()); - meta.put("path", path); - if (operation.getTags() != null) meta.put("tags", operation.getTags()); - if (operation.getSecurity() != null) meta.put("security", operation.getSecurity()); - if (!pathParams.isEmpty()) meta.put("pathParams", pathParams); - if (!queryParams.isEmpty()) meta.put("queryParams", queryParams); + meta.put(CommonConstant.HTTP_METHOD, method.name()); + meta.put(CommonConstant.PATH, path); + if (operation.getTags() != null) meta.put(CommonConstant.TAGS, operation.getTags()); + if (operation.getSecurity() != null) meta.put(CommonConstant.SECURITY, operation.getSecurity()); + if (!pathParams.isEmpty()) meta.put(CommonConstant.PATH_PARAMS, pathParams); + if (!queryParams.isEmpty()) meta.put(CommonConstant.QUERY_PARAMS, queryParams); return meta; } @@ -330,32 +336,16 @@ private Swagger parseSwagger(JsonNode jsonNode) { /** * Ensures a unique tool name by appending a numeric suffix if necessary. */ - private String makeUniqueName(String base) { + private String makeUniqueName(Set toolNAMES,String base) { + String name = base; int counter = 1; - while (usedNames.contains(name)) { - name = base + "_" + counter++; + while (toolNAMES.contains(name)) { + name = base + CommonConstant.UNDER_SCORE + counter++; } - usedNames.add(name); + toolNAMES.add(name); return name; } - /** - * Converts an input string into camelCase format. - */ - private static String toCamelCase(String input) { - if (input == null || input.isEmpty()) return ""; - String[] parts = input.split("[^a-zA-Z0-9]+"); - StringBuilder sb = new StringBuilder(); - for (int i = 0; i < parts.length; i++) { - String word = parts[i].toLowerCase(); - if (word.isEmpty()) continue; - if (i == 0) { - sb.append(word); - } else { - sb.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1)); - } - } - return sb.toString(); - } + } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiAuthHandler.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiAuthHandler.java new file mode 100644 index 00000000..792e0119 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiAuthHandler.java @@ -0,0 +1,72 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ +package com.oracle.mcp.openapi.rest; + +import com.oracle.mcp.openapi.enums.OpenApiSchemaAuthType; +import com.oracle.mcp.openapi.model.McpServerConfig; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Base64; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** + * Handler for preparing authentication headers for REST API requests + * based on server configuration. + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ +public class RestApiAuthHandler { + + /** + * Prepares HTTP headers, including authentication headers based on server configuration. + * + * @param config the server configuration + * @return a map of HTTP headers + */ + public Map extractAuthHeaders(McpServerConfig config) { + Map headers = new HashMap<>(); + //headers.put("Accept", "application/json"); + + OpenApiSchemaAuthType authType = config.getAuthType(); + if (authType == null) { + authType = OpenApiSchemaAuthType.NONE; + } + + switch (authType) { + case NONE: + break; + case BASIC: + char[] passwordChars = config.getAuthPassword(); + assert passwordChars != null; + String password = new String(passwordChars); + String encoded = Base64.getEncoder().encodeToString( + (config.getAuthUsername() + ":" + password).getBytes(StandardCharsets.UTF_8) + ); + headers.put("Authorization", "Basic " + encoded); + Arrays.fill(passwordChars, ' '); + break; + case BEARER: + char[] tokenChars = config.getAuthToken(); + assert tokenChars != null; + String token = new String(tokenChars); + headers.put("Authorization", "Bearer " + token); + Arrays.fill(tokenChars, ' '); + break; + case API_KEY: + if ("header".equalsIgnoreCase(config.getAuthApiKeyIn())) { + headers.put(config.getAuthApiKeyName(), new String(Objects.requireNonNull(config.getAuthApiKey()))); + } + break; + case CUSTOM: + headers.putAll(config.getAuthCustomHeaders()); + break; + } + return headers; + } +} diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutor.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutor.java index 571903c9..9878434b 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutor.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutor.java @@ -8,8 +8,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.oracle.mcp.openapi.cache.McpServerCacheService; +import com.oracle.mcp.openapi.constants.CommonConstant; import com.oracle.mcp.openapi.enums.OpenApiSchemaAuthType; import com.oracle.mcp.openapi.model.McpServerConfig; +import com.oracle.mcp.openapi.rest.RestApiAuthHandler; import com.oracle.mcp.openapi.rest.RestApiExecutionService; import io.modelcontextprotocol.server.McpSyncServerExchange; import io.modelcontextprotocol.spec.McpSchema; @@ -19,7 +21,14 @@ import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import java.util.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; /** * Executes OpenAPI-based MCP tools. This class translates MCP tool requests @@ -40,6 +49,7 @@ public class OpenApiMcpToolExecutor { private final McpServerCacheService mcpServerCacheService; private final RestApiExecutionService restApiExecutionService; private final ObjectMapper jsonMapper; + private final RestApiAuthHandler restApiAuthHandler; /** * Constructs a new {@code OpenApiMcpToolExecutor}. @@ -50,10 +60,11 @@ public class OpenApiMcpToolExecutor { */ public OpenApiMcpToolExecutor(McpServerCacheService mcpServerCacheService, RestApiExecutionService restApiExecutionService, - ObjectMapper jsonMapper) { + ObjectMapper jsonMapper, RestApiAuthHandler restApiAuthHandler) { this.mcpServerCacheService = mcpServerCacheService; this.restApiExecutionService = restApiExecutionService; this.jsonMapper = jsonMapper; + this.restApiAuthHandler = restApiAuthHandler; } /** @@ -83,7 +94,7 @@ public McpSchema.CallToolResult execute(McpSchema.CallToolRequest callRequest) { try { McpSchema.Tool toolToExecute = mcpServerCacheService.getTool(callRequest.name()); String httpMethod = toolToExecute.meta().get("httpMethod").toString().toUpperCase(); - String path = toolToExecute.meta().get("path").toString(); + String path = toolToExecute.meta().get(CommonConstant.PATH).toString(); McpServerConfig config = mcpServerCacheService.getServerConfig(); Map arguments = new HashMap<>(callRequest.arguments()); @@ -93,7 +104,7 @@ public McpSchema.CallToolResult execute(McpSchema.CallToolRequest callRequest) { finalUrl = appendQueryParameters(finalUrl, toolToExecute, arguments, config); // Prepare headers and request body - Map headers = prepareHeaders(config); + Map headers = restApiAuthHandler.extractAuthHeaders(config); String body = null; if (shouldHaveBody(httpMethod)) { body = jsonMapper.writeValueAsString(arguments); @@ -108,8 +119,8 @@ public McpSchema.CallToolResult execute(McpSchema.CallToolRequest callRequest) { LOGGER.error("Execution failed for tool '{}': {}", callRequest.name(), e.getMessage()); throw new RuntimeException("Failed to execute tool: " + callRequest.name(), e); } - - String wrappedResponse = "{\"response\":" + response + "}"; + Map wrappedResponse = new HashMap<>(); + wrappedResponse.put("response",response); return McpSchema.CallToolResult.builder() .structuredContent(wrappedResponse) .build(); diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolInitializer.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolInitializer.java index 0974fae1..a5f32b93 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolInitializer.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolInitializer.java @@ -8,6 +8,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.oracle.mcp.openapi.cache.McpServerCacheService; +import com.oracle.mcp.openapi.constants.CommonConstant; +import com.oracle.mcp.openapi.constants.ErrorMessage; +import com.oracle.mcp.openapi.exception.McpServerToolInitializeException; import com.oracle.mcp.openapi.exception.UnsupportedApiDefinitionException; import com.oracle.mcp.openapi.mapper.impl.OpenApiToMcpToolMapper; import com.oracle.mcp.openapi.mapper.impl.SwaggerToMcpToolMapper; @@ -15,7 +18,8 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.*; +import java.util.List; + /** * Initializes and extracts {@link McpSchema.Tool} objects from OpenAPI or Swagger specifications. @@ -54,10 +58,9 @@ public OpenApiMcpToolInitializer(McpServerCacheService mcpServerCacheService) { * @throws IllegalArgumentException if {@code openApiJson} is {@code null} * @throws UnsupportedApiDefinitionException if the API definition is not recognized */ - public List extractTools(JsonNode openApiJson) { + public List extractTools(JsonNode openApiJson) throws McpServerToolInitializeException { LOGGER.debug("Parsing OpenAPI JsonNode to OpenAPI object..."); List mcpTools = parseApi(openApiJson); - LOGGER.debug("Conversion complete. Total tools created: {}", mcpTools.size()); updateToolsToCache(mcpTools); return mcpTools; @@ -90,17 +93,17 @@ private void updateToolsToCache(List tools) { * @throws IllegalArgumentException if {@code jsonNode} is {@code null} * @throws UnsupportedApiDefinitionException if the specification type is unsupported */ - private List parseApi(JsonNode jsonNode) { + private List parseApi(JsonNode jsonNode) throws McpServerToolInitializeException { if (jsonNode == null) { throw new IllegalArgumentException("jsonNode cannot be null"); } // Detect version - if (jsonNode.has("openapi")) { + if (jsonNode.has(CommonConstant.OPEN_API)) { return new OpenApiToMcpToolMapper(mcpServerCacheService).convert(jsonNode); - } else if (jsonNode.has("swagger")) { + } else if (jsonNode.has(CommonConstant.SWAGGER)) { return new SwaggerToMcpToolMapper(mcpServerCacheService).convert(jsonNode); } else { - throw new UnsupportedApiDefinitionException("Unsupported API definition: missing 'openapi' or 'swagger' field"); + throw new McpServerToolInitializeException(ErrorMessage.INVALID_SPEC_DEFINITION); } } } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/util/McpServerUtil.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/util/McpServerUtil.java new file mode 100644 index 00000000..07def130 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/util/McpServerUtil.java @@ -0,0 +1,38 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ +package com.oracle.mcp.openapi.util; + +/** + * Utility class for MCP Server operations. + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ +public final class McpServerUtil { + + private static final String CAMEL_CASE_REGEX = "[^a-zA-Z0-9]+"; + + /** + * Converts an input string into camelCase format. + */ + public static String toCamelCase(String input) { + if (input == null || input.isEmpty()){ + return ""; + } + String[] parts = input.split(CAMEL_CASE_REGEX); + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < parts.length; i++) { + String word = parts[i].toLowerCase(); + if (word.isEmpty()) continue; + if (i == 0) { + sb.append(word); + } else { + sb.append(Character.toUpperCase(word.charAt(0))).append(word.substring(1)); + } + } + return sb.toString(); + } + +} diff --git a/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/OpenApiMcpServerTest.java b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/OpenApiMcpServerTest.java index aaa5d6fd..87d3d317 100644 --- a/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/OpenApiMcpServerTest.java +++ b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/OpenApiMcpServerTest.java @@ -1,10 +1,12 @@ package com.oracle.mcp.openapi; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.github.tomakehurst.wiremock.WireMockServer; import com.github.tomakehurst.wiremock.core.WireMockConfiguration; import com.oracle.mcp.openapi.cache.McpServerCacheService; import com.oracle.mcp.openapi.model.McpServerConfig; +import com.oracle.mcp.openapi.rest.RestApiAuthHandler; import com.oracle.mcp.openapi.rest.RestApiExecutionService; import com.oracle.mcp.openapi.tool.OpenApiMcpToolExecutor; import io.modelcontextprotocol.server.McpAsyncServer; @@ -33,45 +35,49 @@ ) class OpenApiMcpServerTest { - @Autowired - private McpSyncServer mcpSyncServer; - - @Autowired - private RestApiExecutionService restApiExecutionService; - - @Autowired - private McpServerCacheService mcpServerCacheService; - - @Autowired - private ApplicationContext context; + @Autowired private McpSyncServer mcpSyncServer; + @Autowired private RestApiExecutionService restApiExecutionService; + @Autowired private McpServerCacheService mcpServerCacheService; + @Autowired private RestApiAuthHandler restApiAuthHandler; + @Autowired private ApplicationContext context; private static final ObjectMapper objectMapper = new ObjectMapper(); private static String expectedTools; - private static String getAllCompaniesToolResponse; - private static String getOneCompanyToolResponse; - private static String getUpdateCompanyToolResponse; + private static String companiesResponse; + private static String companyResponse; @BeforeAll static void setup() throws Exception { - // Start WireMock once - WireMockServer wireMockServer = new WireMockServer(WireMockConfiguration.options() - .port(8080) - .usingFilesUnderDirectory("src/test/resources")); + // Start WireMock once for all tests + WireMockServer wireMockServer = new WireMockServer( + WireMockConfiguration.options() + .port(8080) + .usingFilesUnderDirectory("src/test/resources") + ); wireMockServer.start(); - // Load test resources once + // Load test resources expectedTools = readFile("src/test/resources/tools/listTool.json"); - getAllCompaniesToolResponse = readFile("src/test/resources/__files/companies-response.json"); - getOneCompanyToolResponse = readFile("src/test/resources/__files/company-1-response.json"); - getUpdateCompanyToolResponse = getOneCompanyToolResponse; - System.out.println("WireMock server started on port 8080"); + companiesResponse = readFile("src/test/resources/__files/companies-response.json"); + companyResponse = readFile("src/test/resources/__files/company-1-response.json"); } private static String readFile(String path) throws Exception { - String content = Files.readString(Paths.get(path)); - assertNotNull(content, "File not found: " + path); - return content; + return Files.readString(Paths.get(path)); + } + + private OpenApiMcpToolExecutor newExecutor(McpServerCacheService cache) { + return new OpenApiMcpToolExecutor(cache, restApiExecutionService, objectMapper, restApiAuthHandler); + } + + private String executeTool(McpServerCacheService cache, String toolName, String input) throws Exception { + OpenApiMcpToolExecutor executor = newExecutor(cache); + McpSchema.CallToolRequest request = new McpSchema.CallToolRequest(toolName, input); + McpSchema.CallToolResult result = executor.execute(request); + Object resultObj = result.structuredContent().get("response"); + JsonNode jsonNode = objectMapper.readTree((String)resultObj); + return objectMapper.writeValueAsString(jsonNode); } @Test @@ -98,16 +104,12 @@ void testExecuteGetAllTools_BasicAuth() throws Exception { "getCompanies" ); - OpenApiMcpToolExecutor executor = new OpenApiMcpToolExecutor(cacheService, restApiExecutionService, objectMapper); - McpSchema.CallToolRequest request = new McpSchema.CallToolRequest("getCompanies", "{}"); - McpSchema.CallToolResult result = executor.execute(request); - - String response = objectMapper.writeValueAsString(result.structuredContent().get("response")); - assertEquals(getAllCompaniesToolResponse, response); + String response = executeTool(cacheService, "getCompanies", "{}"); + assertEquals(companiesResponse, response); } @Test - void testExecuteGetAllTools_BearerAuth() throws Exception { + void testExecuteGetOneCompany_BearerAuth() throws Exception { McpServerCacheService cacheService = mockConfig( McpServerConfig.builder() .apiBaseUrl("http://localhost:8080") @@ -117,12 +119,8 @@ void testExecuteGetAllTools_BearerAuth() throws Exception { "getCompanyById" ); - OpenApiMcpToolExecutor executor = new OpenApiMcpToolExecutor(cacheService, restApiExecutionService, objectMapper); - McpSchema.CallToolRequest request = new McpSchema.CallToolRequest("getCompanyById", "{\"companyId\":1}"); - McpSchema.CallToolResult result = executor.execute(request); - - String response = objectMapper.writeValueAsString(result.structuredContent().get("response")); - assertEquals(getOneCompanyToolResponse, response); + String response = executeTool(cacheService, "getCompanyById", "{\"companyId\":1}"); + assertEquals(companyResponse, response); } @Test @@ -133,20 +131,14 @@ void testExecuteCreateCompany_ApiKeyAuth() throws Exception { .authType("API_KEY") .authApiKeyIn("HEADER") .authApiKeyName("X-API-KEY") - .authApiKey("test-api-key".toCharArray()) // <- new field in your config + .authApiKey("test-api-key".toCharArray()) .build(), "createCompany" ); - OpenApiMcpToolExecutor executor = new OpenApiMcpToolExecutor(cacheService, restApiExecutionService, objectMapper); - McpSchema.CallToolRequest request = new McpSchema.CallToolRequest( - "createCompany", - "{ \"name\": \"Test Company\", \"address\": \"123 Main St\" }" - ); - McpSchema.CallToolResult result = executor.execute(request); - - String response = objectMapper.writeValueAsString(result.structuredContent().get("response")); - assertEquals(getOneCompanyToolResponse, response); // should match __files/company-1-response.json + String response = executeTool(cacheService, "createCompany", + "{ \"name\": \"Test Company\", \"address\": \"123 Main St\" }"); + assertEquals(companyResponse, response); } @Test @@ -155,27 +147,16 @@ void testExecuteUpdateCompany_CustomAuth() throws Exception { McpServerConfig.builder() .apiBaseUrl("http://localhost:8080") .authType("CUSTOM") - .authCustomHeaders(Map.of("CUSTOM-HEADER","test-custom-key")) + .authCustomHeaders(Map.of("CUSTOM-HEADER", "test-custom-key")) .build(), "updateCompany" ); - OpenApiMcpToolExecutor executor = new OpenApiMcpToolExecutor(cacheService, restApiExecutionService, objectMapper); - - McpSchema.CallToolRequest request = new McpSchema.CallToolRequest( - "updateCompany", - "{ \"companyId\": 1, \"name\": \"Acme Corp - Updated\", \"industry\": \"Technology\" }" - ); - - McpSchema.CallToolResult result = executor.execute(request); - - String response = objectMapper.writeValueAsString(result.structuredContent().get("response")); - assertEquals(getUpdateCompanyToolResponse, response); + String response = executeTool(cacheService, "updateCompany", + "{ \"companyId\": 1, \"name\": \"Acme Corp - Updated\", \"industry\": \"Technology\" }"); + assertEquals(companyResponse, response); } - - - private McpServerCacheService mockConfig(McpServerConfig config, String toolName) { McpServerCacheService mockCache = Mockito.mock(McpServerCacheService.class); Mockito.when(mockCache.getServerConfig()).thenReturn(config); diff --git a/src/openapi-mcp-server/src/test/resources/tools/listTool.json b/src/openapi-mcp-server/src/test/resources/tools/listTool.json index dc114de3..9d7f8007 100644 --- a/src/openapi-mcp-server/src/test/resources/tools/listTool.json +++ b/src/openapi-mcp-server/src/test/resources/tools/listTool.json @@ -1 +1 @@ -[{"tool":{"name":"getCompanies","title":"Get all companies","description":"Get all companies","inputSchema":{"type":"object","additionalProperties":false},"outputSchema":{"type":"object","properties":{"response":{"type":"array","items":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":"string"}}}}},"additionalProperties":true},"_meta":{"httpMethod":"GET","path":"/rest/v1/companies"}},"call":null,"callHandler":{}},{"tool":{"name":"createCompany","title":"Create a new company","description":"Create a new company","inputSchema":{"type":"object","properties":{"name":{"type":"string"},"industry":{"type":"string"},"address":{"type":"string"}},"required":["industry","name"],"additionalProperties":false},"outputSchema":{"type":"object","additionalProperties":true},"_meta":{"httpMethod":"POST","path":"/rest/v1/companies"}},"call":null,"callHandler":{}},{"tool":{"name":"getCompanyById","title":"Get a company by ID","description":"Get a company by ID","inputSchema":{"type":"object","properties":{"companyId":{"type":"integer","description":"The ID of the company to retrieve."}},"required":["companyId"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"response":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":"string"},"industry":{"type":"string"},"address":{"type":"string"}}}},"additionalProperties":true},"_meta":{"httpMethod":"GET","path":"/rest/v1/companies/{companyId}","pathParams":{"companyId":{"name":"companyId","required":true,"description":"The ID of the company to retrieve.","type":"integer"}}}},"call":null,"callHandler":{}},{"tool":{"name":"updateCompany","title":"Update a company","description":"Update a company","inputSchema":{"type":"object","properties":{"companyId":{"type":"integer","description":"The ID of the company to update."},"name":{"type":"string"},"industry":{"type":"string"},"address":{"type":"string"}},"required":["companyId"],"additionalProperties":false},"outputSchema":{"type":"object","properties":{"response":{"type":"object","properties":{"id":{"type":"integer"},"name":{"type":"string"},"industry":{"type":"string"},"address":{"type":"string"}}}},"additionalProperties":true},"_meta":{"httpMethod":"PATCH","path":"/rest/v1/companies/{companyId}","pathParams":{"companyId":{"name":"companyId","required":true,"description":"The ID of the company to update.","type":"integer"}}}},"call":null,"callHandler":{}}] \ No newline at end of file +[{"tool":{"name":"getCompanies","title":"Get all companies","description":"Get all companies","inputSchema":{"type":"object","additionalProperties":false},"outputSchema":{"additionalProperties":true},"_meta":{"httpMethod":"GET","path":"/rest/v1/companies"}},"call":null,"callHandler":{}},{"tool":{"name":"createCompany","title":"Create a new company","description":"Create a new company","inputSchema":{"type":"object","properties":{"name":{"type":"string"},"industry":{"type":"string"},"address":{"type":"string"}},"required":["industry","name"],"additionalProperties":false},"outputSchema":{"additionalProperties":true},"_meta":{"httpMethod":"POST","path":"/rest/v1/companies"}},"call":null,"callHandler":{}},{"tool":{"name":"getCompanyById","title":"Get a company by ID","description":"Get a company by ID","inputSchema":{"type":"object","properties":{"companyId":{"type":"integer","description":"The ID of the company to retrieve."}},"required":["companyId"],"additionalProperties":false},"outputSchema":{"additionalProperties":true},"_meta":{"httpMethod":"GET","path":"/rest/v1/companies/{companyId}","pathParams":{"companyId":{"name":"companyId","required":true,"description":"The ID of the company to retrieve.","type":"integer"}}}},"call":null,"callHandler":{}},{"tool":{"name":"updateCompany","title":"Update a company","description":"Update a company","inputSchema":{"type":"object","properties":{"companyId":{"type":"integer","description":"The ID of the company to update."},"name":{"type":"string"},"industry":{"type":"string"},"address":{"type":"string"}},"required":["companyId"],"additionalProperties":false},"outputSchema":{"additionalProperties":true},"_meta":{"httpMethod":"PATCH","path":"/rest/v1/companies/{companyId}","pathParams":{"companyId":{"name":"companyId","required":true,"description":"The ID of the company to update.","type":"integer"}}}},"call":null,"callHandler":{}}] \ No newline at end of file From 716d14ad7e2621648943428678f6ce15a850c3b2 Mon Sep 17 00:00:00 2001 From: Joby Wilson Mathews Date: Mon, 8 Sep 2025 10:24:46 +0530 Subject: [PATCH 10/12] Added more junits and fixed override mcp tools --- .../oracle/mcp/openapi/OpenApiMcpServer.java | 2 +- .../mcp/openapi/constants/CommonConstant.java | 1 + .../mcp/openapi/constants/ErrorMessage.java | 3 + .../mcp/openapi/mapper/McpToolMapper.java | 83 ++++++- .../mapper/impl/OpenApiToMcpToolMapper.java | 89 ++++--- .../mapper/impl/SwaggerToMcpToolMapper.java | 102 +++++--- .../mcp/openapi/model/McpServerConfig.java | 25 ++ .../openapi/model/override/ToolOverride.java | 70 ++++++ .../model/override/ToolOverridesConfig.java | 64 +++++ .../openapi/tool/OpenApiMcpToolExecutor.java | 68 ++---- .../tool/OpenApiMcpToolInitializer.java | 20 +- .../mcp/openapi/util/McpServerUtil.java | 22 ++ .../mcp/openapi/OpenApiMcpServerTest.java | 16 +- .../mcp/openapi/mapper/McpToolMapperTest.java | 83 +++++++ .../impl/OpenApiToMcpToolMapperTest.java | 114 +++++++++ .../impl/SwaggerToMcpToolMapperTest.java | 99 ++++++++ .../openapi/model/McpServerConfigTest.java | 11 + .../tool/OpenApiMcpToolExecutorTest.java | 227 ++++++++++++++++++ 18 files changed, 972 insertions(+), 127 deletions(-) create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/override/ToolOverride.java create mode 100644 src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/override/ToolOverridesConfig.java create mode 100644 src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/mapper/McpToolMapperTest.java create mode 100644 src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapperTest.java create mode 100644 src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapperTest.java create mode 100644 src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutorTest.java diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java index 2b0921dd..cfb69c3e 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java @@ -113,7 +113,7 @@ private void initialize(String[] args) throws Exception { // Fetch and convert OpenAPI to tools JsonNode openApiJson = openApiSchemaFetcher.fetch(argument); - List mcpTools = openApiMcpToolInitializer.extractTools(openApiJson); + List mcpTools = openApiMcpToolInitializer.extractTools(argument,openApiJson); // Build MCP server capabilities McpSchema.ServerCapabilities serverCapabilities = McpSchema.ServerCapabilities.builder() diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/CommonConstant.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/CommonConstant.java index e7344089..9fd93c13 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/CommonConstant.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/CommonConstant.java @@ -36,4 +36,5 @@ public interface CommonConstant { //REST constants String APPLICATION_JSON = "application/json"; + } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/ErrorMessage.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/ErrorMessage.java index acd23037..0c4e193c 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/ErrorMessage.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/ErrorMessage.java @@ -25,4 +25,7 @@ public interface ErrorMessage { String MISSING_PATH_IN_SPEC = "'paths' object not found in the specification."; String INVALID_SPEC_DEFINITION = "Unsupported API definition: missing 'openapi' or 'swagger' field"; + + String INVALID_SWAGGER_SPEC = "Invalid Swagger specification provided."; + } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/McpToolMapper.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/McpToolMapper.java index 6c567b30..9e8ae05f 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/McpToolMapper.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/McpToolMapper.java @@ -8,9 +8,13 @@ import com.fasterxml.jackson.databind.JsonNode; import com.oracle.mcp.openapi.exception.McpServerToolInitializeException; +import com.oracle.mcp.openapi.model.override.ToolOverridesConfig; +import com.oracle.mcp.openapi.util.McpServerUtil; import io.modelcontextprotocol.spec.McpSchema; +import java.util.Collections; import java.util.List; +import java.util.Set; /** * Mapper interface for converting OpenAPI specifications into @@ -27,10 +31,87 @@ public interface McpToolMapper { /** * Converts an OpenAPI specification into a list of MCP tools. * + * @param toolOverridesConfig the tool overrides configuration. * @param apiSpec the OpenAPI specification represented as a {@link JsonNode}; * must not be {@code null}. * @return a list of {@link McpSchema.Tool} objects derived from the given API specification; * never {@code null}, but may be empty if no tools are found. */ - List convert(JsonNode apiSpec) throws McpServerToolInitializeException; + List convert(JsonNode apiSpec,ToolOverridesConfig toolOverridesConfig) throws McpServerToolInitializeException; + + default String generateToolName( + String method, + String path, + String operationId, + Set existingNames) { + + String baseName; + + if (operationId != null && !operationId.isEmpty()) { + baseName = operationId; + } else { + StringBuilder name = new StringBuilder(method.toLowerCase()); + for (String segment : path.split("/")) { + if (segment.isEmpty()){ + continue; + } + + if (segment.startsWith("{") && segment.endsWith("}")) { + String varName = segment.substring(1, segment.length() - 1); + name.append("By").append(McpServerUtil.capitalize(varName)); + } else { + name.append(McpServerUtil.capitalize(segment)); + } + } + baseName = name.toString(); + } + + String uniqueName = baseName; + + // Resolve clash: add HTTP method if already taken + if (existingNames.contains(uniqueName)) { + // Append method suffix if not already included + String methodSuffix = McpServerUtil.capitalize(method.toLowerCase()); + if (!uniqueName.endsWith(methodSuffix)) { + uniqueName = baseName + methodSuffix; + } + // In the rare case it's STILL a clash, append last path segment + if (existingNames.contains(uniqueName)) { + String lastSegment = getLastPathSegment(path); + uniqueName = baseName + McpServerUtil.capitalize(method.toLowerCase()) + McpServerUtil.capitalize(lastSegment); + } + } + + existingNames.add(uniqueName); + return uniqueName; + } + + + default String getLastPathSegment(String path) { + String[] segments = path.split("/"); + for (int i = segments.length - 1; i >= 0; i--) { + if (!segments[i].isEmpty() && !segments[i].startsWith("{")) { + return segments[i]; + } + } + return "Endpoint"; + } + + default boolean skipTool(String toolName, ToolOverridesConfig config) { + if (config == null) { + return false; + } + + Set includeOnly = config.getIncludeOnly() == null + ? Collections.emptySet() + : config.getIncludeOnly(); + + Set exclude = config.getExclude() == null + ? Collections.emptySet() + : config.getExclude(); + + // Apply filtering: Exclude wins + return (!includeOnly.isEmpty() && !includeOnly.contains(toolName)) + || exclude.contains(toolName); + } } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapper.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapper.java index 2a435c6d..76f6e80b 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapper.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapper.java @@ -12,6 +12,8 @@ import com.oracle.mcp.openapi.constants.ErrorMessage; import com.oracle.mcp.openapi.exception.McpServerToolInitializeException; import com.oracle.mcp.openapi.mapper.McpToolMapper; +import com.oracle.mcp.openapi.model.override.ToolOverride; +import com.oracle.mcp.openapi.model.override.ToolOverridesConfig; import com.oracle.mcp.openapi.util.McpServerUtil; import io.modelcontextprotocol.spec.McpSchema; import io.swagger.v3.oas.models.OpenAPI; @@ -22,6 +24,7 @@ import io.swagger.v3.oas.models.parameters.Parameter; import io.swagger.v3.parser.OpenAPIV3Parser; import io.swagger.v3.parser.core.models.ParseOptions; +import io.swagger.v3.parser.core.models.SwaggerParseResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -55,7 +58,7 @@ public OpenApiToMcpToolMapper(McpServerCacheService mcpServerCacheService) { } @Override - public List convert(JsonNode openApiJson) throws McpServerToolInitializeException { + public List convert(JsonNode openApiJson,ToolOverridesConfig toolOverridesConfig) throws McpServerToolInitializeException { LOGGER.debug("Parsing OpenAPI schema to OpenAPI object."); OpenAPI openAPI = parseOpenApi(openApiJson); LOGGER.debug("Successfully parsed OpenAPI schema"); @@ -64,13 +67,13 @@ public List convert(JsonNode openApiJson) throws McpServerToolIn throw new McpServerToolInitializeException(ErrorMessage.MISSING_PATH_IN_SPEC); } - List mcpTools = processPaths(openAPI); + List mcpTools = processPaths(openAPI,toolOverridesConfig); LOGGER.debug("Conversion complete. Total tools created: {}", mcpTools.size()); updateToolsToCache(mcpTools); return mcpTools; } - private List processPaths(OpenAPI openAPI) { + private List processPaths(OpenAPI openAPI, ToolOverridesConfig toolOverridesConfig) { List mcpTools = new ArrayList<>(); Set toolNames = new HashSet<>(); for (Map.Entry pathEntry : openAPI.getPaths().entrySet()) { @@ -81,12 +84,12 @@ private List processPaths(OpenAPI openAPI) { continue; } - processOperationsForPath(path, pathItem, mcpTools,toolNames); + processOperationsForPath(path, pathItem, mcpTools,toolNames,toolOverridesConfig); } return mcpTools; } - private void processOperationsForPath(String path, PathItem pathItem, List mcpTools,Set toolNames) { + private void processOperationsForPath(String path, PathItem pathItem, List mcpTools, Set toolNames, ToolOverridesConfig toolOverridesConfig) { for (Map.Entry methodEntry : pathItem.readOperationsMap().entrySet()) { PathItem.HttpMethod method = methodEntry.getKey(); Operation operation = methodEntry.getValue(); @@ -94,25 +97,26 @@ private void processOperationsForPath(String path, PathItem pathItem, List toolNames) { - String rawOperationId = (operation.getOperationId() != null && !operation.getOperationId().isEmpty()) - ? operation.getOperationId() - : McpServerUtil.toCamelCase(method.name() + " " + path); + private McpSchema.Tool buildToolFromOperation(String path, PathItem.HttpMethod method, Operation operation, Set toolNames, ToolOverridesConfig toolOverridesConfig) { - String toolName = makeUniqueName(toolNames,rawOperationId); - LOGGER.debug("--- Parsing Operation: {} {} (ID: {}) ---", method.name().toUpperCase(), path, toolName); + String toolName = generateToolName(method.name(), path, operation.getOperationId(),toolNames); - String toolTitle = (operation.getSummary() != null && !operation.getSummary().isEmpty()) - ? operation.getSummary() - : toolName; - String toolDescription = getDescription(operation); + if(skipTool(toolName,toolOverridesConfig)){ + LOGGER.debug("Skipping tool: {} as it is in tool override file", toolName); + return null; + } + + LOGGER.debug("--- Parsing Operation: {} {} (ID: {}) ---", method.name().toUpperCase(), path, toolName); + ToolOverride toolOverride = toolOverridesConfig.getTools().getOrDefault(toolName,ToolOverride.EMPTY_TOOL_OVERRIDE); + String toolTitle = getToolTitle(operation,toolOverride,toolName); + String toolDescription = getToolDescription(operation,toolOverride); Map properties = new LinkedHashMap<>(); List requiredParams = new ArrayList<>(); @@ -143,6 +147,31 @@ private McpSchema.Tool buildToolFromOperation(String path, PathItem.HttpMethod m .build(); } + private String getToolTitle(Operation operation, ToolOverride toolOverride,String toolName) { + String overrideTitle = toolOverride.getTitle(); + if (McpServerUtil.isNotBlank(overrideTitle)) { + return overrideTitle; + } + return (operation.getSummary() != null && !operation.getSummary().isEmpty()) + ? operation.getSummary() + : toolName; + } + + + private String getToolDescription(Operation operation, ToolOverride toolOverride) { + String overrideDescription = toolOverride.getDescription(); + if (McpServerUtil.isNotBlank(overrideDescription)) { + return overrideDescription; + } + if (McpServerUtil.isNotBlank(operation.getSummary())) { + return operation.getSummary(); + } + if (McpServerUtil.isNotBlank(operation.getDescription())) { + return operation.getDescription(); + } + return ""; + } + private Map getOutputSchema() { Map outputSchema = new HashMap<>(); outputSchema.put(CommonConstant.ADDITIONAL_PROPERTIES, true); @@ -209,21 +238,17 @@ private void updateToolsToCache(List tools) { } } - private String getDescription(Operation operation) { - if (operation.getSummary() != null && !operation.getSummary().isEmpty()) { - return operation.getSummary(); - } else if (operation.getDescription() != null && !operation.getDescription().isEmpty()) { - return operation.getDescription(); - } - return ""; - } - private OpenAPI parseOpenApi(JsonNode jsonNode) { String jsonString = jsonNode.toString(); ParseOptions options = new ParseOptions(); options.setResolve(true); options.setResolveFully(true); - return new OpenAPIV3Parser().readContents(jsonString, null, options).getOpenAPI(); + SwaggerParseResult result = new OpenAPIV3Parser().readContents(jsonString, null, options); + List messages = result.getMessages(); + if (messages != null && !messages.isEmpty()) { + LOGGER.info("OpenAPI validation errors: {}", messages); + } + return result.getOpenAPI(); } private void extractRequestBody(Operation operation, Map properties, List requiredParams) { @@ -243,7 +268,7 @@ private void extractRequestBody(Operation operation, Map propert } } - private Map extractInputSchema(Schema openApiSchema) { + protected Map extractInputSchema(Schema openApiSchema) { if (openApiSchema == null) { return new LinkedHashMap<>(); } @@ -295,13 +320,5 @@ private Map parameterMetaMap(Parameter p) { return paramMeta; } - private String makeUniqueName(Set toolNames,String base) { - String name = base; - int counter = 1; - while (toolNames.contains(name)) { - name = base + CommonConstant.UNDER_SCORE + counter++; - } - toolNames.add(name); - return name; - } + } \ No newline at end of file diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java index cb40bdf5..490d1bf2 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java @@ -9,7 +9,11 @@ import com.fasterxml.jackson.databind.JsonNode; import com.oracle.mcp.openapi.cache.McpServerCacheService; import com.oracle.mcp.openapi.constants.CommonConstant; +import com.oracle.mcp.openapi.constants.ErrorMessage; +import com.oracle.mcp.openapi.exception.McpServerToolInitializeException; import com.oracle.mcp.openapi.mapper.McpToolMapper; +import com.oracle.mcp.openapi.model.override.ToolOverride; +import com.oracle.mcp.openapi.model.override.ToolOverridesConfig; import com.oracle.mcp.openapi.util.McpServerUtil; import io.modelcontextprotocol.spec.McpSchema; import io.swagger.models.ArrayModel; @@ -56,7 +60,6 @@ public class SwaggerToMcpToolMapper implements McpToolMapper { private final McpServerCacheService mcpServerCacheService; private static final Logger LOGGER = LoggerFactory.getLogger(SwaggerToMcpToolMapper.class); - private Swagger swaggerSpec; // Stores the full spec to resolve $ref definitions /** * Creates a new {@code SwaggerToMcpToolMapper}. @@ -76,15 +79,18 @@ public SwaggerToMcpToolMapper(McpServerCacheService mcpServerCacheService) { * @throws IllegalArgumentException if the specification does not contain a {@code paths} object. */ @Override - public List convert(JsonNode swaggerJson) { + public List convert(JsonNode swaggerJson, ToolOverridesConfig toolOverridesConfig) throws McpServerToolInitializeException { LOGGER.debug("Parsing Swagger 2 JsonNode to Swagger object..."); - this.swaggerSpec = parseSwagger(swaggerJson); + Swagger swaggerSpec = parseSwagger(swaggerJson); + if(swaggerSpec ==null){ + throw new McpServerToolInitializeException(ErrorMessage.INVALID_SWAGGER_SPEC); + } if (swaggerSpec.getPaths() == null || swaggerSpec.getPaths().isEmpty()) { - throw new IllegalArgumentException("'paths' object not found in the specification."); + throw new McpServerToolInitializeException(ErrorMessage.MISSING_PATH_IN_SPEC); } - List mcpTools = processPaths(swaggerSpec); + List mcpTools = processPaths(swaggerSpec,toolOverridesConfig); LOGGER.debug("Conversion complete. Total tools created: {}", mcpTools.size()); updateToolsToCache(mcpTools); return mcpTools; @@ -93,10 +99,11 @@ public List convert(JsonNode swaggerJson) { /** * Processes all paths in the Swagger specification and builds corresponding MCP tools. * - * @param swagger the parsed Swagger object. + * @param swagger the parsed Swagger object. + * @param toolOverridesConfig the tool overrides configuration. * @return list of {@link McpSchema.Tool}. */ - private List processPaths(Swagger swagger) { + private List processPaths(Swagger swagger, ToolOverridesConfig toolOverridesConfig) { List mcpTools = new ArrayList<>(); Set toolNames = new HashSet<>(); if (swagger.getPaths() == null) return mcpTools; @@ -108,7 +115,7 @@ private List processPaths(Swagger swagger) { continue; } - processOperationsForPath(path, pathItem, mcpTools,toolNames); + processOperationsForPath(swagger,path, pathItem, mcpTools,toolNames,toolOverridesConfig); } return mcpTools; } @@ -116,18 +123,19 @@ private List processPaths(Swagger swagger) { /** * Extracts operations (GET, POST, etc.) for a given Swagger path and converts them to MCP tools. * - * @param path the API path (e.g., {@code /users}). - * @param pathItem the Swagger path item containing operations. - * @param mcpTools the list to which new tools will be added. + * @param path the API path (e.g., {@code /users}). + * @param pathItem the Swagger path item containing operations. + * @param mcpTools the list to which new tools will be added. + * @param toolOverridesConfig the tool overrides configuration. */ - private void processOperationsForPath(String path, Path pathItem, List mcpTools,Set toolNames) { + private void processOperationsForPath(Swagger swaggerSpec, String path, Path pathItem, List mcpTools, Set toolNames, ToolOverridesConfig toolOverridesConfig) { Map operations = pathItem.getOperationMap(); if (operations == null){ return; } for (Map.Entry methodEntry : operations.entrySet()) { - McpSchema.Tool tool = buildToolFromOperation(path, methodEntry.getKey(), methodEntry.getValue(),toolNames); + McpSchema.Tool tool = buildToolFromOperation(swaggerSpec,path, methodEntry.getKey(), methodEntry.getValue(),toolNames,toolOverridesConfig); if (tool != null) { mcpTools.add(tool); } @@ -137,21 +145,26 @@ private void processOperationsForPath(String path, Path pathItem, List toolNames) { + private McpSchema.Tool buildToolFromOperation(Swagger swaggerSpec, String path, HttpMethod method, Operation operation, Set toolNames, ToolOverridesConfig toolOverridesConfig) { String rawOperationId = (operation.getOperationId() != null && !operation.getOperationId().isEmpty()) ? operation.getOperationId() : McpServerUtil.toCamelCase(method.name() + " " + path); String toolName = makeUniqueName(toolNames,rawOperationId); + if(skipTool(toolName,toolOverridesConfig)){ + LOGGER.debug("Skipping tool: {} as it is in tool override file", toolName); + return null; + } LOGGER.debug("--- Parsing Operation: {} {} (ID: {}) ---", method.name(), path, toolName); - - String toolTitle = operation.getSummary() != null ? operation.getSummary() : toolName; - String toolDescription = operation.getDescription() != null ? operation.getDescription() : toolTitle; + ToolOverride toolOverride = toolOverridesConfig.getTools().getOrDefault(toolName,ToolOverride.EMPTY_TOOL_OVERRIDE); + String toolTitle = getToolTitle(operation,toolOverride,toolName); + String toolDescription = getToolDescription(operation,toolOverride); Map properties = new LinkedHashMap<>(); List requiredParams = new ArrayList<>(); @@ -159,7 +172,7 @@ private McpSchema.Tool buildToolFromOperation(String path, HttpMethod method, Op Map> pathParams = new HashMap<>(); Map> queryParams = new HashMap<>(); extractPathAndQueryParams(operation, pathParams, queryParams, properties, requiredParams); - extractRequestBody(operation, properties, requiredParams); + extractRequestBody(swaggerSpec,operation, properties, requiredParams); McpSchema.JsonSchema inputSchema = new McpSchema.JsonSchema( CommonConstant.OBJECT, @@ -181,6 +194,31 @@ private McpSchema.Tool buildToolFromOperation(String path, HttpMethod method, Op .build(); } + private String getToolDescription(Operation operation, ToolOverride toolOverride) { + String overrideDescription = toolOverride.getDescription(); + if (McpServerUtil.isNotBlank(overrideDescription)) { + return overrideDescription; + } + if (McpServerUtil.isNotBlank(operation.getSummary())) { + return operation.getSummary(); + } + if (McpServerUtil.isNotBlank(operation.getDescription())) { + return operation.getDescription(); + } + return ""; + + } + + private String getToolTitle(Operation operation, ToolOverride toolOverride, String toolName) { + String overrideTitle = toolOverride.getTitle(); + if (McpServerUtil.isNotBlank(overrideTitle)) { + return overrideTitle; + } + return (operation.getSummary() != null && !operation.getSummary().isEmpty()) + ? operation.getSummary() + : toolName; + } + private Map getOutputSchema() { Map outputSchema = new HashMap<>(); outputSchema.put(CommonConstant.ADDITIONAL_PROPERTIES, true); @@ -223,7 +261,7 @@ private void extractPathAndQueryParams(Operation operation, /** * Extracts request body schema (if present) from a Swagger operation. */ - private void extractRequestBody(Operation operation, Map properties, List requiredParams) { + private void extractRequestBody(Swagger swaggerSpec,Operation operation, Map properties, List requiredParams) { if (operation.getParameters() == null){ return; } @@ -234,7 +272,7 @@ private void extractRequestBody(Operation operation, Map propert .ifPresent(p -> { BodyParameter bodyParam = (BodyParameter) p; Model schema = bodyParam.getSchema(); - Map bodyProps = extractModelSchema(schema); + Map bodyProps = extractModelSchema(swaggerSpec,schema); if (!bodyProps.isEmpty()) { properties.put(bodyParam.getName(), bodyProps); @@ -248,17 +286,17 @@ private void extractRequestBody(Operation operation, Map propert /** * Recursively extracts schema details from a Swagger model definition. */ - private Map extractModelSchema(Model model) { + private Map extractModelSchema(Swagger swagger,Model model) { if (model instanceof RefModel) { String ref = ((RefModel) model).getSimpleRef(); - model = swaggerSpec.getDefinitions().get(ref); + model = swagger.getDefinitions().get(ref); } Map schema = new LinkedHashMap<>(); if (model instanceof ModelImpl && model.getProperties() != null) { Map props = new LinkedHashMap<>(); model.getProperties().forEach((key, prop) -> - props.put(key, extractPropertySchema(prop)) + props.put(key, extractPropertySchema(swagger,prop)) ); schema.put(CommonConstant.TYPE, CommonConstant.OBJECT); schema.put(CommonConstant.PROPERTIES, props); @@ -267,7 +305,7 @@ private Map extractModelSchema(Model model) { } } else if (model instanceof ArrayModel) { schema.put(CommonConstant.TYPE, CommonConstant.ARRAY); - schema.put(CommonConstant.ITEMS, extractPropertySchema(((ArrayModel) model).getItems())); + schema.put(CommonConstant.ITEMS, extractPropertySchema(swagger,((ArrayModel) model).getItems())); } return schema; } @@ -275,12 +313,12 @@ private Map extractModelSchema(Model model) { /** * Extracts schema information from a Swagger property. */ - private Map extractPropertySchema(Property property) { + private Map extractPropertySchema(Swagger swagger,Property property) { if (property instanceof RefProperty) { String simpleRef = ((RefProperty) property).getSimpleRef(); - Model definition = swaggerSpec.getDefinitions().get(simpleRef); + Model definition = swagger.getDefinitions().get(simpleRef); if (definition != null) { - return extractModelSchema(definition); + return extractModelSchema(swagger,definition); } else { return Map.of("type", CommonConstant.OBJECT, CommonConstant.DESCRIPTION, "Unresolved reference: " + simpleRef); } @@ -293,11 +331,11 @@ private Map extractPropertySchema(Property property) { if (property instanceof ObjectProperty) { Map props = new LinkedHashMap<>(); ((ObjectProperty) property).getProperties().forEach((key, prop) -> - props.put(key, extractPropertySchema(prop)) + props.put(key, extractPropertySchema(swagger,prop)) ); schema.put(CommonConstant.PROPERTIES, props); } else if (property instanceof ArrayProperty) { - schema.put(CommonConstant.ITEMS, extractPropertySchema(((ArrayProperty) property).getItems())); + schema.put(CommonConstant.ITEMS, extractPropertySchema(swagger,((ArrayProperty) property).getItems())); } return schema; } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/McpServerConfig.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/McpServerConfig.java index 289e0f6a..cb18a568 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/McpServerConfig.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/McpServerConfig.java @@ -12,6 +12,7 @@ import com.oracle.mcp.openapi.constants.ErrorMessage; import com.oracle.mcp.openapi.enums.OpenApiSchemaAuthType; import com.oracle.mcp.openapi.exception.McpServerToolInitializeException; +import com.oracle.mcp.openapi.model.override.ToolOverridesConfig; import java.util.Collections; import java.util.HashMap; @@ -52,6 +53,7 @@ public final class McpServerConfig { private final String redirectPolicy; private final String proxyHost; private final Integer proxyPort; + private final String toolOverridesJson; public McpServerConfig(Builder builder) { this.apiName = builder.apiName; @@ -71,6 +73,7 @@ public McpServerConfig(Builder builder) { this.redirectPolicy = builder.redirectPolicy; this.proxyHost = builder.proxyHost; this.proxyPort = builder.proxyPort; + this.toolOverridesJson = builder.toolOverridesJson; } // ----------------- GETTERS ----------------- @@ -164,6 +167,18 @@ public Integer getProxyPort() { return proxyPort; } + public String getToolOverridesJson() { + return toolOverridesJson; + } + + public ToolOverridesConfig getToolOverridesConfig() throws JsonProcessingException { + String toolOverridesJson = getToolOverridesJson(); + if(toolOverridesJson==null){ + return ToolOverridesConfig.EMPTY_TOOL_OVERRIDE_CONFIG; + } + return OBJECT_MAPPER.readValue(toolOverridesJson,ToolOverridesConfig.class); + } + // ----------------- BUILDER ----------------- public static class Builder { private String apiName; @@ -183,6 +198,7 @@ public static class Builder { private String redirectPolicy; private String proxyHost; private Integer proxyPort; + private String toolOverridesJson; public Builder apiName(String apiName) { this.apiName = apiName; @@ -269,6 +285,13 @@ public Builder proxyPort(Integer proxyPort) { return this; } + public Builder toolOverridesJson(String toolOverridesJson) { + this.toolOverridesJson = toolOverridesJson; + return this; + } + + + public McpServerConfig build() { return new McpServerConfig(this); } @@ -299,6 +322,7 @@ public static McpServerConfig fromArgs(String[] args) throws McpServerToolInitia String authApiKeyName = getStringValue(argMap.get("--auth-api-key-name"), "API_API_KEY_NAME"); String authApiKeyIn = getStringValue(argMap.get("--auth-api-key-in"), "API_API_KEY_IN"); + String toolOverridesJson = getStringValue(argMap.get("--tool-overrides"), "MCP_TOOL_OVERRIDES"); // Validation for API key if ("API_KEY".equalsIgnoreCase(authType)) { @@ -370,6 +394,7 @@ public static McpServerConfig fromArgs(String[] args) throws McpServerToolInitia .redirectPolicy(redirectPolicy) .proxyHost(proxyHost) .proxyPort(proxyPort) + .toolOverridesJson(toolOverridesJson) .build(); } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/override/ToolOverride.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/override/ToolOverride.java new file mode 100644 index 00000000..2497fba7 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/override/ToolOverride.java @@ -0,0 +1,70 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ +package com.oracle.mcp.openapi.model.override; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Map; + +/** + * Model class representing overrides for a specific tool. + * Allows customization of tool properties such as name, title, description, and input schema. + * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public class ToolOverride { + + public static final ToolOverride EMPTY_TOOL_OVERRIDE = new ToolOverride(); + + @JsonProperty("name") + private String name; + + @JsonProperty("title") + private String title; + + @JsonProperty("description") + private String description; + + @JsonProperty("inputSchema") + private Map inputSchema; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getTitle() { + return title; + } + + public void setTitle(String title) { + this.title = title; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public Map getInputSchema() { + return inputSchema; + } + + public void setInputSchema(Map inputSchema) { + this.inputSchema = inputSchema; + } +} \ No newline at end of file diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/override/ToolOverridesConfig.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/override/ToolOverridesConfig.java new file mode 100644 index 00000000..a069abb6 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/override/ToolOverridesConfig.java @@ -0,0 +1,64 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ +package com.oracle.mcp.openapi.model.override; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Collections; +import java.util.Map; +import java.util.Set; +/** + * Model class representing configuration for tool overrides. + * Allows specifying which tools to include or exclude, and detailed overrides for specific tools. + * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@JsonIgnoreProperties(ignoreUnknown = true) +public class ToolOverridesConfig { + + public final static ToolOverridesConfig EMPTY_TOOL_OVERRIDE_CONFIG = new ToolOverridesConfig(); + + public ToolOverridesConfig(){ + + } + + @JsonProperty("includeOnly") + private Set includeOnly = Collections.emptySet(); + + @JsonProperty("exclude") + private Set exclude = Collections.emptySet(); + + @JsonProperty("tools") + private Map tools = Collections.emptyMap(); + + public Set getIncludeOnly() { + return includeOnly; + } + + public void setIncludeOnly(Set includeOnly) { + this.includeOnly = includeOnly; + } + + public Set getExclude() { + return exclude; + } + + public void setExclude(Set exclude) { + this.exclude = exclude; + } + + public Map getTools() { + return tools==null?Collections.emptyMap():tools; + } + + public void setTools(Map tools) { + this.tools = tools; + } +} diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutor.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutor.java index 9878434b..bb1d84b7 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutor.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutor.java @@ -22,8 +22,6 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; -import java.util.Arrays; -import java.util.Base64; import java.util.Collections; import java.util.HashMap; import java.util.List; @@ -136,15 +134,30 @@ public McpSchema.CallToolResult execute(McpSchema.CallToolRequest callRequest) { * @return the final URL with substituted path parameters */ private String substitutePathParameters(String url, McpSchema.Tool tool, Map arguments) { - Map pathParams = (Map) tool.meta().getOrDefault("pathParams", Collections.emptyMap()); + if (tool == null || tool.meta() == null) { + return url; + } + + Map pathParams = + (Map) tool.meta().getOrDefault("pathParams", Collections.emptyMap()); + String finalUrl = url; + for (String paramName : pathParams.keySet()) { if (arguments.containsKey(paramName)) { String value = String.valueOf(arguments.get(paramName)); - finalUrl = finalUrl.replace("{" + paramName + "}", URLEncoder.encode(value, StandardCharsets.UTF_8)); + + // Proper encoding for path variables (spaces → %20 instead of +) + String encoded = URLEncoder.encode(value, StandardCharsets.UTF_8) + .replace("+", "%20"); + + finalUrl = finalUrl.replace("{" + paramName + "}", encoded); + + // remove consumed argument so it doesn't get added again as query param arguments.remove(paramName); } } + return finalUrl; } @@ -187,53 +200,6 @@ private String appendQueryParameters(String url, McpSchema.Tool tool, Map prepareHeaders(McpServerConfig config) { - Map headers = new HashMap<>(); - headers.put("Accept", "application/json"); - - OpenApiSchemaAuthType authType = config.getAuthType(); - if (authType == null) { - authType = OpenApiSchemaAuthType.NONE; - } - - switch (authType) { - case NONE: - break; - case BASIC: - char[] passwordChars = config.getAuthPassword(); - assert passwordChars != null; - String password = new String(passwordChars); - String encoded = Base64.getEncoder().encodeToString( - (config.getAuthUsername() + ":" + password).getBytes(StandardCharsets.UTF_8) - ); - headers.put("Authorization", "Basic " + encoded); - Arrays.fill(passwordChars, ' '); - break; - case BEARER: - char[] tokenChars = config.getAuthToken(); - assert tokenChars != null; - String token = new String(tokenChars); - headers.put("Authorization", "Bearer " + token); - Arrays.fill(tokenChars, ' '); - break; - case API_KEY: - if ("header".equalsIgnoreCase(config.getAuthApiKeyIn())) { - headers.put(config.getAuthApiKeyName(), new String(Objects.requireNonNull(config.getAuthApiKey()))); - } - break; - case CUSTOM: - headers.putAll(config.getAuthCustomHeaders()); - break; - } - return headers; - } - /** * Determines whether the HTTP request should have a body. * diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolInitializer.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolInitializer.java index a5f32b93..47e248db 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolInitializer.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolInitializer.java @@ -6,6 +6,7 @@ */ package com.oracle.mcp.openapi.tool; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.oracle.mcp.openapi.cache.McpServerCacheService; import com.oracle.mcp.openapi.constants.CommonConstant; @@ -14,6 +15,8 @@ import com.oracle.mcp.openapi.exception.UnsupportedApiDefinitionException; import com.oracle.mcp.openapi.mapper.impl.OpenApiToMcpToolMapper; import com.oracle.mcp.openapi.mapper.impl.SwaggerToMcpToolMapper; +import com.oracle.mcp.openapi.model.McpServerConfig; +import com.oracle.mcp.openapi.model.override.ToolOverridesConfig; import io.modelcontextprotocol.spec.McpSchema; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -58,9 +61,9 @@ public OpenApiMcpToolInitializer(McpServerCacheService mcpServerCacheService) { * @throws IllegalArgumentException if {@code openApiJson} is {@code null} * @throws UnsupportedApiDefinitionException if the API definition is not recognized */ - public List extractTools(JsonNode openApiJson) throws McpServerToolInitializeException { + public List extractTools(McpServerConfig serverConfig,JsonNode openApiJson) throws McpServerToolInitializeException { LOGGER.debug("Parsing OpenAPI JsonNode to OpenAPI object..."); - List mcpTools = parseApi(openApiJson); + List mcpTools = parseApi(serverConfig,openApiJson); LOGGER.debug("Conversion complete. Total tools created: {}", mcpTools.size()); updateToolsToCache(mcpTools); return mcpTools; @@ -93,17 +96,24 @@ private void updateToolsToCache(List tools) { * @throws IllegalArgumentException if {@code jsonNode} is {@code null} * @throws UnsupportedApiDefinitionException if the specification type is unsupported */ - private List parseApi(JsonNode jsonNode) throws McpServerToolInitializeException { + private List parseApi(McpServerConfig serverConfig,JsonNode jsonNode) throws McpServerToolInitializeException { if (jsonNode == null) { throw new IllegalArgumentException("jsonNode cannot be null"); } + ToolOverridesConfig toolOverridesJson = null; + try { + toolOverridesJson = serverConfig.getToolOverridesConfig(); + } catch (JsonProcessingException e) { + LOGGER.warn("Failed to parse tool overrides JSON: {}", e.getMessage()); + } // Detect version if (jsonNode.has(CommonConstant.OPEN_API)) { - return new OpenApiToMcpToolMapper(mcpServerCacheService).convert(jsonNode); + return new OpenApiToMcpToolMapper(mcpServerCacheService).convert(jsonNode,toolOverridesJson); } else if (jsonNode.has(CommonConstant.SWAGGER)) { - return new SwaggerToMcpToolMapper(mcpServerCacheService).convert(jsonNode); + return new SwaggerToMcpToolMapper(mcpServerCacheService).convert(jsonNode,toolOverridesJson); } else { throw new McpServerToolInitializeException(ErrorMessage.INVALID_SPEC_DEFINITION); } + } } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/util/McpServerUtil.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/util/McpServerUtil.java index 07def130..9956d19b 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/util/McpServerUtil.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/util/McpServerUtil.java @@ -34,5 +34,27 @@ public static String toCamelCase(String input) { } return sb.toString(); } + /** + * Capitalizes the first letter of the given string. + * If the string is null or empty, it is returned as is. + * + * @param str The input string to capitalize. + * @return The capitalized string, or the original string if it is null or empty. + */ + public static String capitalize(String str) { + if (str == null || str.isEmpty()){ + return str; + } + return str.substring(0, 1).toUpperCase() + str.substring(1); + } + /** + * Checks if a string is not null and not empty. + * + * @param str The string to check. + * @return true if the string is not null and not empty, false otherwise. + */ + public static boolean isNotBlank(String str) { + return str != null && !str.isEmpty(); + } } diff --git a/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/OpenApiMcpServerTest.java b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/OpenApiMcpServerTest.java index 87d3d317..93ab56d7 100644 --- a/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/OpenApiMcpServerTest.java +++ b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/OpenApiMcpServerTest.java @@ -1,3 +1,9 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ package com.oracle.mcp.openapi; import com.fasterxml.jackson.databind.JsonNode; @@ -25,8 +31,16 @@ import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +/** + * Integration tests for the OpenAPI MCP server using a mocked OpenAPI specification. + *

+ * This test class uses WireMock to simulate an OpenAPI server with various authentication methods. + * It verifies that the MCP server correctly initializes tools and executes them with different auth types. + * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ @SpringBootTest( args = { "--api-spec", "http://localhost:8080/rest/v1/metadata-catalog/companies", diff --git a/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/mapper/McpToolMapperTest.java b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/mapper/McpToolMapperTest.java new file mode 100644 index 00000000..54c31913 --- /dev/null +++ b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/mapper/McpToolMapperTest.java @@ -0,0 +1,83 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ +package com.oracle.mcp.openapi.mapper; + +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +/** + * Unit tests for {@link McpToolMapper}. + * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ +public class McpToolMapperTest { + + private Set existingNames; + + private static McpToolMapper toolNameTestMapper; + + @BeforeAll + static void init() { + toolNameTestMapper = (apiSpec, toolOverridesConfig) -> List.of(); + } + + @BeforeEach + void setUp() { + existingNames = new HashSet<>(); + } + + @Test + void shouldUseOperationIdWhenPresent() { + String result = toolNameTestMapper.generateToolName("get", "/users", "listUsers", existingNames); + assertEquals("listUsers", result); + } + + @Test + void shouldFallbackToMethodAndPathWhenNoOperationId() { + String result = toolNameTestMapper.generateToolName("get", "/users", null, existingNames); + assertEquals("getUsers", result); + } + + @Test + void shouldHandlePathVariables() { + String result = toolNameTestMapper.generateToolName("get", "/users/{id}", null, existingNames); + assertEquals("getUsersById", result); + } + + @Test + void shouldDifferentiateByHttpMethod() { + String getName = toolNameTestMapper.generateToolName("get", "/users/{id}", null, existingNames); + String deleteName = toolNameTestMapper.generateToolName("delete", "/users/{id}", null, existingNames); + + assertEquals("getUsersById", getName); + assertEquals("deleteUsersById", deleteName); + } + + @Test + void shouldHandleDuplicateOperationIds() { + String first = toolNameTestMapper.generateToolName("get", "/users", "getUsers", existingNames); + String second = toolNameTestMapper.generateToolName("post", "/users", "getUsers", existingNames); + + assertEquals("getUsers", first); + assertEquals("getUsersPost", second); + } + + @Test + void shouldAppendLastSegmentIfStillClashing() { + existingNames.add("getUsersByIdDelete"); + + String result = toolNameTestMapper.generateToolName("delete", "/users/{id}", null, existingNames); + assertEquals("deleteUsersById", result); + } +} diff --git a/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapperTest.java b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapperTest.java new file mode 100644 index 00000000..12b06ac8 --- /dev/null +++ b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapperTest.java @@ -0,0 +1,114 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ +package com.oracle.mcp.openapi.mapper.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.oracle.mcp.openapi.cache.McpServerCacheService; +import com.oracle.mcp.openapi.constants.ErrorMessage; +import com.oracle.mcp.openapi.exception.McpServerToolInitializeException; +import com.oracle.mcp.openapi.model.override.ToolOverridesConfig; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +class OpenApiToMcpToolMapperTest { + + private McpServerCacheService cacheService; + private OpenApiToMcpToolMapper mapper; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + cacheService = mock(McpServerCacheService.class); + mapper = new OpenApiToMcpToolMapper(cacheService); + objectMapper = new ObjectMapper(); + } + + @Test + void convert_ShouldReturnToolList_ForValidOpenApiJson() throws McpServerToolInitializeException { + // Arrange: simple OpenAPI JSON with one GET /users path + ObjectNode openApiJson = objectMapper.createObjectNode(); + openApiJson.put("openapi", "3.0.0"); + + ObjectNode paths = objectMapper.createObjectNode(); + ObjectNode getOp = objectMapper.createObjectNode(); + getOp.put("operationId", "getUsers"); + paths.set("/users", objectMapper.createObjectNode().set("get", getOp)); + openApiJson.set("paths", paths); + + ToolOverridesConfig overrides = new ToolOverridesConfig(); // empty overrides + + // Act + List tools = mapper.convert(openApiJson, overrides); + + // Assert + assertNotNull(tools); + assertEquals(1, tools.size()); + McpSchema.Tool tool = tools.getFirst(); + assertEquals("getUsers", tool.name()); + assertEquals("getUsers", tool.title()); // operationId used as fallback + verify(cacheService).putTool(eq("getUsers"), any(McpSchema.Tool.class)); + } + + @Test + void convert_ShouldThrowException_WhenPathsMissing() { + ObjectNode openApiJson = objectMapper.createObjectNode(); + openApiJson.put("openapi", "3.0.0"); + + ToolOverridesConfig overrides = new ToolOverridesConfig(); + + McpServerToolInitializeException ex = assertThrows(McpServerToolInitializeException.class, () -> + mapper.convert(openApiJson, overrides) + ); + assertEquals(ErrorMessage.MISSING_PATH_IN_SPEC, ex.getMessage()); + } + + @Test + void convert_ShouldSkipTool_WhenOverrideExists() throws McpServerToolInitializeException { + // Arrange OpenAPI JSON + ObjectNode openApiJson = objectMapper.createObjectNode(); + openApiJson.put("openapi", "3.0.0"); + + ObjectNode paths = objectMapper.createObjectNode(); + ObjectNode getOp = objectMapper.createObjectNode(); + getOp.put("operationId", "skipTool"); // used to generate toolName + paths.set("/skip", objectMapper.createObjectNode().set("get", getOp)); + openApiJson.set("paths", paths); + + // Arrange override config to skip the tool + ToolOverridesConfig overrides = new ToolOverridesConfig(); + overrides.setExclude(Set.of("skipTool")); // exact toolName generated by generateToolName() + + // Act + List tools = mapper.convert(openApiJson, overrides); + + // Assert + assertTrue(tools.isEmpty(), "Tool should be skipped due to exclude list"); + verify(cacheService, never()).putTool(anyString(), any(McpSchema.Tool.class)); + } + + + @Test + void extractInputSchema_ShouldHandleNullSchema() { + Map result = mapper.extractInputSchema(null); + assertNotNull(result); + assertTrue(result.isEmpty()); + } +} diff --git a/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapperTest.java b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapperTest.java new file mode 100644 index 00000000..cdf8675b --- /dev/null +++ b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapperTest.java @@ -0,0 +1,99 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ +package com.oracle.mcp.openapi.mapper.impl; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.oracle.mcp.openapi.cache.McpServerCacheService; +import com.oracle.mcp.openapi.constants.ErrorMessage; +import com.oracle.mcp.openapi.exception.McpServerToolInitializeException; +import com.oracle.mcp.openapi.model.override.ToolOverridesConfig; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class SwaggerToMcpToolMapperTest { + + private SwaggerToMcpToolMapper swaggerMapper; + private McpServerCacheService cacheService; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + cacheService = mock(McpServerCacheService.class); + swaggerMapper = new SwaggerToMcpToolMapper(cacheService); + objectMapper = new ObjectMapper(); + } + + @Test + void convert_ShouldCreateTool_WhenOperationPresent() throws McpServerToolInitializeException { + // Arrange + ObjectNode swaggerJson = objectMapper.createObjectNode(); + swaggerJson.put("swagger", "2.0"); + + ObjectNode paths = objectMapper.createObjectNode(); + ObjectNode getOp = objectMapper.createObjectNode(); + getOp.put("operationId", "testTool"); + paths.set("/test", objectMapper.createObjectNode().set("get", getOp)); + swaggerJson.set("paths", paths); + + ToolOverridesConfig overrides = new ToolOverridesConfig(); + + // Act + List tools = swaggerMapper.convert(swaggerJson, overrides); + + // Assert + assertEquals(1, tools.size(), "One tool should be created"); + assertEquals("testTool", tools.get(0).name()); + verify(cacheService).putTool("testTool", tools.get(0)); + } + + @Test + void convert_ShouldSkipTool_WhenInExcludeList() throws McpServerToolInitializeException { + // Arrange + ObjectNode swaggerJson = objectMapper.createObjectNode(); + swaggerJson.put("swagger", "2.0"); + + ObjectNode paths = objectMapper.createObjectNode(); + ObjectNode getOp = objectMapper.createObjectNode(); + getOp.put("operationId", "skipTool"); + paths.set("/skip", objectMapper.createObjectNode().set("get", getOp)); + swaggerJson.set("paths", paths); + + ToolOverridesConfig overrides = new ToolOverridesConfig(); + overrides.setExclude(Set.of("skipTool")); + + // Act + List tools = swaggerMapper.convert(swaggerJson, overrides); + + // Assert + assertTrue(tools.isEmpty(), "Tool should be skipped due to exclude list"); + verify(cacheService, never()).putTool(anyString(), any(McpSchema.Tool.class)); + } + + @Test + void convert_ShouldThrowException_WhenPathsMissing() { + // Arrange + ObjectNode swaggerJson = objectMapper.createObjectNode(); + swaggerJson.put("swagger", "2.0"); + + ToolOverridesConfig overrides = new ToolOverridesConfig(); + + // Act & Assert + McpServerToolInitializeException ex = assertThrows( + McpServerToolInitializeException.class, + () -> swaggerMapper.convert(swaggerJson, overrides) + ); + assertEquals(ErrorMessage.MISSING_PATH_IN_SPEC, ex.getMessage()); + } +} diff --git a/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/model/McpServerConfigTest.java b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/model/McpServerConfigTest.java index d16abe82..79ecc500 100644 --- a/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/model/McpServerConfigTest.java +++ b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/model/McpServerConfigTest.java @@ -1,3 +1,9 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ package com.oracle.mcp.openapi.model; import com.oracle.mcp.openapi.constants.ErrorMessage; @@ -9,6 +15,11 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +/** + * Unit tests for {@link McpServerConfig}. + * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ class McpServerConfigTest { @Test diff --git a/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutorTest.java b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutorTest.java new file mode 100644 index 00000000..d2b34695 --- /dev/null +++ b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutorTest.java @@ -0,0 +1,227 @@ +/* + * -------------------------------------------------------------------------- + * Copyright (c) 2025, Oracle and/or its affiliates. + * Licensed under the Universal Permissive License v1.0 as shown at http://oss.oracle.com/licenses/upl. + * -------------------------------------------------------------------------- + */ +package com.oracle.mcp.openapi.tool; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.oracle.mcp.openapi.cache.McpServerCacheService; +import com.oracle.mcp.openapi.constants.CommonConstant; +import com.oracle.mcp.openapi.enums.OpenApiSchemaAuthType; +import com.oracle.mcp.openapi.model.McpServerConfig; +import com.oracle.mcp.openapi.rest.RestApiAuthHandler; +import com.oracle.mcp.openapi.rest.RestApiExecutionService; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.io.IOException; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for {@link OpenApiMcpToolExecutor}. + * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ +@ExtendWith(MockitoExtension.class) +class OpenApiMcpToolExecutorTest { + + @Mock + private McpServerCacheService mcpServerCacheService; + + @Mock + private RestApiExecutionService restApiExecutionService; + + @Mock + private RestApiAuthHandler restApiAuthHandler; + + @Spy + private ObjectMapper jsonMapper = new ObjectMapper(); + + @InjectMocks + private OpenApiMcpToolExecutor openApiMcpToolExecutor; + + @Captor + private ArgumentCaptor urlCaptor; + + @Captor + private ArgumentCaptor methodCaptor; + + @Captor + private ArgumentCaptor bodyCaptor; + + @Captor + private ArgumentCaptor> headersCaptor; + + private McpServerConfig serverConfig; + + @BeforeEach + void setUp() { + serverConfig = new McpServerConfig.Builder().apiBaseUrl("https://api.example.com").build(); + } + + /** + * Tests a successful execution of a POST request with path and query parameters. + */ + @Test + void execute_PostRequest_Successful() throws IOException, InterruptedException { + // Arrange + Map arguments = new HashMap<>(); + arguments.put("userId", 123); + arguments.put("filter", "active"); + arguments.put("requestData", Map.of("name", "test")); + + McpSchema.CallToolRequest callRequest = McpSchema.CallToolRequest.builder() + .name("createUser") + .arguments(arguments) + .build(); + + Map meta = new HashMap<>(); + meta.put("httpMethod", "POST"); + meta.put(CommonConstant.PATH, "/users/{userId}"); + meta.put("pathParams", Map.of("userId", "integer")); + meta.put("queryParams", Map.of("filter", "string")); + + McpSchema.Tool tool = McpSchema.Tool.builder() + .name("createUser") + .meta(meta) + .build(); + + when(mcpServerCacheService.getTool("createUser")).thenReturn(tool); + when(mcpServerCacheService.getServerConfig()).thenReturn(serverConfig); + Map headers = new HashMap<>(); + headers.put("Authorization", "Bearer token"); + when(restApiAuthHandler.extractAuthHeaders(serverConfig)).thenReturn(headers); + when(restApiExecutionService.executeRequest(anyString(), anyString(), anyString(), any())) + .thenReturn("{\"status\":\"success\"}"); + + // Act + McpSchema.CallToolResult result = openApiMcpToolExecutor.execute(callRequest); + + // Assert + assertNotNull(result); + assertTrue(result.structuredContent().containsKey("response")); + assertEquals("{\"status\":\"success\"}", result.structuredContent().get("response")); + + verify(restApiExecutionService).executeRequest(urlCaptor.capture(), methodCaptor.capture(), bodyCaptor.capture(), headersCaptor.capture()); + assertEquals("https://api.example.com/users/123?filter=active", urlCaptor.getValue()); + assertEquals("POST", methodCaptor.getValue()); + assertEquals("{\"requestData\":{\"name\":\"test\"}}", bodyCaptor.getValue()); + assertEquals("Bearer token", headersCaptor.getValue().get("Authorization")); + assertEquals("application/json", headersCaptor.getValue().get("Content-Type")); + } + + /** + * Tests a successful execution of a GET request, ensuring no request body is sent. + */ + @Test + void execute_GetRequest_Successful() throws IOException, InterruptedException { + // Arrange + McpSchema.CallToolRequest callRequest = McpSchema.CallToolRequest.builder() + .name("getUser") + .arguments(Collections.emptyMap()) + .build(); + + Map meta = Map.of("httpMethod", "GET", CommonConstant.PATH, "/user"); + McpSchema.Tool tool = McpSchema.Tool.builder().name("getUser").meta(meta).build(); + + when(mcpServerCacheService.getTool("getUser")).thenReturn(tool); + when(mcpServerCacheService.getServerConfig()).thenReturn(serverConfig); + when(restApiExecutionService.executeRequest(anyString(), anyString(), any(), any())) + .thenReturn("{\"id\":1}"); + + // Act + openApiMcpToolExecutor.execute(callRequest); + + // Assert + verify(restApiExecutionService).executeRequest(urlCaptor.capture(), methodCaptor.capture(), bodyCaptor.capture(), any()); + assertEquals("https://api.example.com/user", urlCaptor.getValue()); + assertEquals("GET", methodCaptor.getValue()); + assertNull(bodyCaptor.getValue()); // No body for GET requests + } + + /** + * Tests that an API key is correctly appended to the query parameters when configured. + */ + @Test + void execute_ApiKeyInQuery_Successful() throws IOException, InterruptedException { + // Arrange + serverConfig = new McpServerConfig.Builder().apiBaseUrl("https://api.example.com") + .authType(OpenApiSchemaAuthType.API_KEY.name()) + .authApiKeyIn("query") + .authApiKeyName("api_key") + .authApiKey("test-secret-key".toCharArray()).build(); + + McpSchema.CallToolRequest callRequest = McpSchema.CallToolRequest.builder() + .name("getData") + .arguments(Collections.emptyMap()) + .build(); + + Map meta = Map.of("httpMethod", "GET", CommonConstant.PATH, "/data"); + McpSchema.Tool tool = McpSchema.Tool.builder().name("getData").meta(meta).build(); + + when(mcpServerCacheService.getTool("getData")).thenReturn(tool); + when(mcpServerCacheService.getServerConfig()).thenReturn(serverConfig); + + // Act + openApiMcpToolExecutor.execute(callRequest); + + // Assert + verify(restApiExecutionService).executeRequest(urlCaptor.capture(), anyString(), any(), any()); + assertEquals("https://api.example.com/data?api_key=test-secret-key", urlCaptor.getValue()); + } + + /** + * Tests proper URL encoding of path and query parameters. + */ + @Test + void execute_UrlEncoding_Successful() throws IOException, InterruptedException { + // Arrange + Map arguments = new HashMap<>(); + arguments.put("folderName", "my documents/work"); + arguments.put("searchTerm", "a&b=c"); + + McpSchema.CallToolRequest callRequest = McpSchema.CallToolRequest.builder() + .name("searchFiles") + .arguments(arguments) + .build(); + + Map meta = new HashMap<>(); + meta.put("httpMethod", "GET"); + meta.put(CommonConstant.PATH, "/files/{folderName}"); + meta.put("pathParams", Map.of("folderName", "string")); + meta.put("queryParams", Map.of("searchTerm", "string")); + McpSchema.Tool tool = McpSchema.Tool.builder().name("searchFiles").meta(meta).build(); + + when(mcpServerCacheService.getTool("searchFiles")).thenReturn(tool); + when(mcpServerCacheService.getServerConfig()).thenReturn(serverConfig); + + // Act + openApiMcpToolExecutor.execute(callRequest); + + // Assert + verify(restApiExecutionService).executeRequest(urlCaptor.capture(), anyString(), any(), any()); + String expectedUrl = "https://api.example.com/files/my%20documents%2Fwork?searchTerm=a%26b%3Dc"; + assertEquals(expectedUrl, urlCaptor.getValue()); + } +} \ No newline at end of file From 5b400c55a15849b3b555b42a25b5bfe21871689a Mon Sep 17 00:00:00 2001 From: Joby Wilson Mathews Date: Mon, 8 Sep 2025 12:31:53 +0530 Subject: [PATCH 11/12] Updated README.md --- src/openapi-mcp-server/README.md | 40 ++++++++++++++++---------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/src/openapi-mcp-server/README.md b/src/openapi-mcp-server/README.md index e59bfd67..762c716d 100644 --- a/src/openapi-mcp-server/README.md +++ b/src/openapi-mcp-server/README.md @@ -31,26 +31,26 @@ This server acts as a bridge 🌉, dynamically generating **Model Context Protoc --- ## 🔧 Configuration -You can configure the server in two primary ways: command-line arguments or environment variables. This configuration is provided to the MCP client, which then uses it to launch the server. - -### Environment Variables - -The server supports the following environment variables. These can be set within the MCP client's configuration. - -| Environment Variable | Description | Example | -| :--- | :--- | :--- | -| `API_BASE_URL` | Base URL of the API. | `https://api.example.com/v1` | -| `API_SPEC` | Path or URL to the OpenAPI specification. | `/configs/openapi.yaml` | -| `AUTH_TYPE` | Authentication type (`BASIC`, `BEARER`, `API_KEY`). | `BEARER` | -| `AUTH_TOKEN` | Token for Bearer authentication. | `eyJhbGciOiJIUzI1NiIsInR5cCI6...` | -| `AUTH_USERNAME` | Username for Basic authentication. | `adminUser` | -| `AUTH_PASSWORD` | Password for Basic authentication. | `P@ssw0rd!` | -| `AUTH_API_KEY` | API key value for `API_KEY` authentication. | `12345-abcdef-67890` | -| `API_API_KEY_NAME`| Name of the API key parameter. | `X-API-KEY` | -| `API_API_KEY_IN` | Location of API key (`header` or `query`). | `header` | -| `AUTH_CUSTOM_HEADERS`| JSON string of custom authentication headers. | `{"X-Tenant-ID": "acme"}` | -| `API_HTTP_CONNECT_TIMEOUT`| Connection timeout in milliseconds. | `5000` | -| `API_HTTP_RESPONSE_TIMEOUT`| Response timeout in milliseconds. | `10000` | +The MCP OpenAPI Server can be configured via **command-line arguments** or **environment variables**. + +| CLI Argument | Environment Variable | Description | Example | +| :--- | :--- | :--- | :--- | +| `--api-name` | `API_NAME` | Friendly name for the API (used in logs/debug). | `PetStore` | +| `--api-base-url` | `API_BASE_URL` | Base URL of the API. | `https://api.example.com/v1` | +| `--api-spec` | `API_SPEC` | Path or URL to the OpenAPI specification. | `/configs/openapi.yaml` | +| `--auth-type` | `AUTH_TYPE` | Authentication type (`BASIC`, `BEARER`, `API_KEY`). | `BEARER` | +| `--auth-token` | `AUTH_TOKEN` | Token for Bearer authentication. | `eyJhbGciOiJIUzI1NiIsInR5cCI6...` | +| `--auth-username` | `AUTH_USERNAME` | Username for Basic authentication. | `adminUser` | +| `--auth-password` | `AUTH_PASSWORD` | Password for Basic authentication. | `P@ssw0rd!` | +| `--auth-api-key` | `AUTH_API_KEY` | API key value for `API_KEY` authentication. | `12345-abcdef-67890` | +| `--auth-api-key-name` | `API_API_KEY_NAME` | Name of the API key parameter. | `X-API-KEY` | +| `--auth-api-key-in` | `API_API_KEY_IN` | Location of API key (`header` or `query`). | `header` | +| `--auth-custom-headers` | `AUTH_CUSTOM_HEADERS` | JSON string of custom authentication headers. | `{"X-Tenant-ID": "acme"}` | +| `--http-version` | `API_HTTP_VERSION` | HTTP version (`HTTP_1_1`, `HTTP_2`). | `HTTP_2` | +| `--http-redirect` | `API_HTTP_REDIRECT` | Redirect policy (`NEVER`, `NORMAL`, `ALWAYS`). | `NORMAL` | +| `--proxy-host` | `API_HTTP_PROXY_HOST` | Proxy host if needed. | `proxy.example.com` | +| `--proxy-port` | `API_HTTP_PROXY_PORT` | Proxy port number. | `8080` | +| `--tool-overrides` | `MCP_TOOL_OVERRIDES` | JSON string of tool override configuration. | `{ "includeOnly": ["listUsers", "getUser"], "exclude": ["deleteUser"], "tools": [ { "name": "listUsers", "description": "Custom listUsers tool with pagination" ] }` | --- ## 🔌 Integrating with an MCP Client From 4e120664eed05de74e3d1c8a4014f7f23286dcbd Mon Sep 17 00:00:00 2001 From: Joby Wilson Mathews Date: Sun, 14 Sep 2025 23:23:15 +0530 Subject: [PATCH 12/12] 1) Use swagger to aopenapi convertor to get tools for swagger 2.0 schemas. 2) In case of failure httprequest set error in tool result. --- .../mcp/openapi/constants/CommonConstant.java | 2 + .../mapper/impl/OpenApiToMcpToolMapper.java | 400 ++++++++---- .../mapper/impl/SwaggerToMcpToolMapper.java | 358 +--------- .../mcp/openapi/rest/RestApiAuthHandler.java | 5 +- .../openapi/rest/RestApiExecutionService.java | 40 +- .../openapi/tool/OpenApiMcpToolExecutor.java | 10 +- .../impl/OpenApiToMcpToolMapperTest.java | 609 +++++++++++++++++- .../tool/OpenApiMcpToolExecutorTest.java | 55 +- .../src/test/resources/tools/listTool.json | 2 +- 9 files changed, 977 insertions(+), 504 deletions(-) diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/CommonConstant.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/CommonConstant.java index 9fd93c13..2be8db33 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/CommonConstant.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/CommonConstant.java @@ -16,6 +16,8 @@ public interface CommonConstant { String SECURITY = "security"; String PATH_PARAMS = "pathParams"; String QUERY_PARAMS = "queryParams"; + String HEADER_PARAMS = "headerParams"; + String COOKIE_PARAMS = "cookieParams"; // Schema keys String TYPE = "type"; diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapper.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapper.java index 76f6e80b..99f78113 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapper.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapper.java @@ -19,7 +19,6 @@ 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.MediaType; import io.swagger.v3.oas.models.media.Schema; import io.swagger.v3.oas.models.parameters.Parameter; import io.swagger.v3.parser.OpenAPIV3Parser; @@ -29,6 +28,7 @@ import org.slf4j.LoggerFactory; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; @@ -58,16 +58,25 @@ public OpenApiToMcpToolMapper(McpServerCacheService mcpServerCacheService) { } @Override - public List convert(JsonNode openApiJson,ToolOverridesConfig toolOverridesConfig) throws McpServerToolInitializeException { + public List convert(JsonNode openApiJson, ToolOverridesConfig toolOverridesConfig) throws McpServerToolInitializeException { LOGGER.debug("Parsing OpenAPI schema to OpenAPI object."); OpenAPI openAPI = parseOpenApi(openApiJson); LOGGER.debug("Successfully parsed OpenAPI schema"); + return convert(openAPI,toolOverridesConfig); + + } + + public List convert(OpenAPI openAPI, ToolOverridesConfig toolOverridesConfig) throws McpServerToolInitializeException { + if(openAPI==null){ + LOGGER.error("No schema found."); + return Collections.emptyList(); + } if (openAPI.getPaths() == null || openAPI.getPaths().isEmpty()) { - LOGGER.error("There is not paths defined in schema "); + LOGGER.error("No paths defined in schema."); throw new McpServerToolInitializeException(ErrorMessage.MISSING_PATH_IN_SPEC); } - List mcpTools = processPaths(openAPI,toolOverridesConfig); + List mcpTools = processPaths(openAPI, toolOverridesConfig); LOGGER.debug("Conversion complete. Total tools created: {}", mcpTools.size()); updateToolsToCache(mcpTools); return mcpTools; @@ -76,20 +85,21 @@ public List convert(JsonNode openApiJson,ToolOverridesConfig too private List processPaths(OpenAPI openAPI, ToolOverridesConfig toolOverridesConfig) { List mcpTools = new ArrayList<>(); Set toolNames = new HashSet<>(); + for (Map.Entry pathEntry : openAPI.getPaths().entrySet()) { String path = pathEntry.getKey(); LOGGER.debug("Parsing Path: {}", path); PathItem pathItem = pathEntry.getValue(); - if (pathItem == null){ - continue; - } + if (pathItem == null) continue; - processOperationsForPath(path, pathItem, mcpTools,toolNames,toolOverridesConfig); + processOperationsForPath(openAPI, path, pathItem, mcpTools, toolNames, toolOverridesConfig); } return mcpTools; } - private void processOperationsForPath(String path, PathItem pathItem, List mcpTools, Set toolNames, ToolOverridesConfig toolOverridesConfig) { + private void processOperationsForPath(OpenAPI openAPI, String path, PathItem pathItem, + List mcpTools, Set toolNames, + ToolOverridesConfig toolOverridesConfig) { for (Map.Entry methodEntry : pathItem.readOperationsMap().entrySet()) { PathItem.HttpMethod method = methodEntry.getKey(); Operation operation = methodEntry.getValue(); @@ -97,45 +107,46 @@ private void processOperationsForPath(String path, PathItem pathItem, List toolNames, ToolOverridesConfig toolOverridesConfig) { + private McpSchema.Tool buildToolFromOperation(OpenAPI openAPI, String path, PathItem.HttpMethod method, + Operation operation, Set toolNames, + ToolOverridesConfig toolOverridesConfig) { - String toolName = generateToolName(method.name(), path, operation.getOperationId(),toolNames); + String toolName = generateToolName(method.name(), path, operation.getOperationId(), toolNames); - if(skipTool(toolName,toolOverridesConfig)){ + if (skipTool(toolName, toolOverridesConfig)) { LOGGER.debug("Skipping tool: {} as it is in tool override file", toolName); return null; } LOGGER.debug("--- Parsing Operation: {} {} (ID: {}) ---", method.name().toUpperCase(), path, toolName); - ToolOverride toolOverride = toolOverridesConfig.getTools().getOrDefault(toolName,ToolOverride.EMPTY_TOOL_OVERRIDE); - String toolTitle = getToolTitle(operation,toolOverride,toolName); - String toolDescription = getToolDescription(operation,toolOverride); - - Map properties = new LinkedHashMap<>(); - List requiredParams = new ArrayList<>(); - Map> pathParams = new HashMap<>(); - Map> queryParams = new HashMap<>(); - extractPathAndQueryParams(operation, pathParams, queryParams, properties, requiredParams); + ToolOverride toolOverride = toolOverridesConfig.getTools().getOrDefault(toolName, ToolOverride.EMPTY_TOOL_OVERRIDE); + String toolTitle = getToolTitle(operation, toolOverride, toolName); + String toolDescription = getToolDescription(operation, toolOverride); + Map componentsSchemas = openAPI.getComponents() != null ? openAPI.getComponents().getSchemas() : new HashMap<>(); - extractRequestBody(operation, properties, requiredParams); + // Input Schema + McpSchema.JsonSchema inputSchema = getInputSchema(operation, componentsSchemas); - McpSchema.JsonSchema inputSchema = new McpSchema.JsonSchema( - CommonConstant.OBJECT, - properties.isEmpty() ? null : properties, - requiredParams.isEmpty() ? null : requiredParams, - false, null, null - ); + // Output Schema Map outputSchema = getOutputSchema(); - Map meta = buildMeta(method, path, operation, pathParams, queryParams); + // Params + Map> pathParams = new HashMap<>(); + Map> queryParams = new HashMap<>(); + Map> headerParams = new HashMap<>(); + Map> cookieParams = new HashMap<>(); + populatePathQueryHeaderCookieParams(operation, pathParams, queryParams, headerParams, cookieParams); + + // Meta + Map meta = buildMeta(method, path, operation, pathParams, queryParams, headerParams, cookieParams); return McpSchema.Tool.builder() .title(toolTitle) @@ -147,26 +158,64 @@ private McpSchema.Tool buildToolFromOperation(String path, PathItem.HttpMethod m .build(); } - private String getToolTitle(Operation operation, ToolOverride toolOverride,String toolName) { + public void populatePathQueryHeaderCookieParams(Operation operation, + Map> pathParams, + Map> queryParams, + Map> headerParams, + Map> cookieParams) { + if (operation.getParameters() != null) { + for (Parameter param : operation.getParameters()) { + Map propSchema = createPropertySchema( + param.getSchema(), + param.getDescription(), + param.getSchema() != null ? param.getSchema().getEnum() : null + ); + + String name = param.getName(); + + switch (param.getIn()) { + case "path" -> pathParams.put(name, propSchema); + case "query" -> queryParams.put(name, propSchema); + case "header" -> headerParams.put(name, propSchema); + case "cookie" -> cookieParams.put(name, propSchema); + default -> LOGGER.warn("Unknown parameter location: {}", param.getIn()); + } + } + } + } + + private Map createPropertySchema(Schema schema, String description, List enums) { + Map propSchema = new LinkedHashMap<>(); + propSchema.put("type", mapOpenApiType(schema != null ? schema.getType() : null)); + if (description != null){ + propSchema.put("description", description); + } + if (enums != null){ + propSchema.put("enum", enums); + } + return propSchema; + } + + private String getToolTitle(Operation operation, ToolOverride toolOverride, String toolName) { String overrideTitle = toolOverride.getTitle(); - if (McpServerUtil.isNotBlank(overrideTitle)) { + if (McpServerUtil.isNotBlank(overrideTitle)){ return overrideTitle; } + return (operation.getSummary() != null && !operation.getSummary().isEmpty()) ? operation.getSummary() : toolName; } - private String getToolDescription(Operation operation, ToolOverride toolOverride) { String overrideDescription = toolOverride.getDescription(); - if (McpServerUtil.isNotBlank(overrideDescription)) { + if (McpServerUtil.isNotBlank(overrideDescription)){ return overrideDescription; } - if (McpServerUtil.isNotBlank(operation.getSummary())) { + if (McpServerUtil.isNotBlank(operation.getSummary())){ return operation.getSummary(); } - if (McpServerUtil.isNotBlank(operation.getDescription())) { + if (McpServerUtil.isNotBlank(operation.getDescription())){ return operation.getDescription(); } return ""; @@ -174,62 +223,41 @@ private String getToolDescription(Operation operation, ToolOverride toolOverride private Map getOutputSchema() { Map outputSchema = new HashMap<>(); + outputSchema.put("type","object"); outputSchema.put(CommonConstant.ADDITIONAL_PROPERTIES, true); return outputSchema; } - - private Map buildMeta(PathItem.HttpMethod method, String path, - Operation operation, Map> pathParams, - Map> queryParams) { + private Map buildMeta(PathItem.HttpMethod method, String path, Operation operation, + Map> pathParams, + Map> queryParams, + Map> headerParams, + Map> cookieParams) { Map meta = new LinkedHashMap<>(); meta.put(CommonConstant.HTTP_METHOD, method.name()); meta.put(CommonConstant.PATH, path); - if (operation.getTags() != null) { + + if (operation.getTags() != null){ meta.put(CommonConstant.TAGS, operation.getTags()); } - if (operation.getSecurity() != null) { + if (operation.getSecurity() != null){ meta.put(CommonConstant.SECURITY, operation.getSecurity()); } - if (!pathParams.isEmpty()) { + + if (!pathParams.isEmpty()){ meta.put(CommonConstant.PATH_PARAMS, pathParams); } - if (!queryParams.isEmpty()) { + if (!queryParams.isEmpty()){ meta.put(CommonConstant.QUERY_PARAMS, queryParams); } - return meta; - } - - private void extractPathAndQueryParams(Operation operation, - Map> pathParams, - Map> queryParams, - Map properties, - List requiredParams) { - if (operation.getParameters() != null) { - for (Parameter param : operation.getParameters()) { - if (param.getName() == null || param.getSchema() == null){ - continue; - } - - Map paramMeta = parameterMetaMap(param); - if (CommonConstant.PATH.equalsIgnoreCase(param.getIn())) { - pathParams.put(param.getName(), paramMeta); - } else if (CommonConstant.QUERY.equalsIgnoreCase(param.getIn())) { - queryParams.put(param.getName(), paramMeta); - } - - if (CommonConstant.PATH.equalsIgnoreCase(param.getIn()) || CommonConstant.QUERY.equalsIgnoreCase(param.getIn())) { - Map paramSchema = extractInputSchema(param.getSchema()); - if (param.getDescription() != null && !param.getDescription().isEmpty()) { - paramSchema.put(CommonConstant.DESCRIPTION, param.getDescription()); - } - properties.put(param.getName(), paramSchema); - if (Boolean.TRUE.equals(param.getRequired())) { - requiredParams.add(param.getName()); - } - } - } + if (!headerParams.isEmpty()){ + meta.put(CommonConstant.HEADER_PARAMS, headerParams); } + if (!cookieParams.isEmpty()){ + meta.put(CommonConstant.COOKIE_PARAMS, cookieParams); + } + + return meta; } private void updateToolsToCache(List tools) { @@ -243,6 +271,7 @@ private OpenAPI parseOpenApi(JsonNode jsonNode) { ParseOptions options = new ParseOptions(); options.setResolve(true); options.setResolveFully(true); + SwaggerParseResult result = new OpenAPIV3Parser().readContents(jsonString, null, options); List messages = result.getMessages(); if (messages != null && !messages.isEmpty()) { @@ -251,73 +280,204 @@ private OpenAPI parseOpenApi(JsonNode jsonNode) { return result.getOpenAPI(); } - private void extractRequestBody(Operation operation, Map properties, List requiredParams) { + private McpSchema.JsonSchema getInputSchema(Operation operation, Map componentsSchemas) { + Map properties = new LinkedHashMap<>(); + List required = new ArrayList<>(); + Set visitedRefs = new HashSet<>(); + + handleParameters(operation.getParameters(), properties, required, componentsSchemas, visitedRefs); + if (operation.getRequestBody() != null && operation.getRequestBody().getContent() != null) { - MediaType media = operation.getRequestBody().getContent().get(CommonConstant.APPLICATION_JSON); - if (media != null && media.getSchema() != null) { - Schema bodySchema = media.getSchema(); - if (CommonConstant.OBJECT.equals(bodySchema.getType()) && bodySchema.getProperties() != null) { - bodySchema.getProperties().forEach((name, schema) -> - properties.put(name, extractInputSchema((Schema) schema)) - ); - if (bodySchema.getRequired() != null) { - requiredParams.addAll(bodySchema.getRequired()); + Schema bodySchema = operation.getRequestBody().getContent().get("application/json") != null + ? operation.getRequestBody().getContent().get("application/json").getSchema() + : null; + + if (bodySchema != null) { + bodySchema = resolveRef(bodySchema, componentsSchemas, visitedRefs); + Map bodyProps = buildSchemaRecursively(bodySchema, componentsSchemas, visitedRefs); + + // Flatten object-only allOf, keep combinators nested + if ("object".equals(bodyProps.get("type"))) { + Map topProps = new LinkedHashMap<>(); + mergeAllOfProperties(bodyProps, topProps); + // merge top-level properties into final properties + properties.putAll(topProps); + // merge required + if (bodyProps.get("required") instanceof List reqList) { + reqList.forEach(r -> required.add((String) r)); + } + } else { + // Non-object root schema, wrap under "body" + properties.put("body", bodyProps); + if (Boolean.TRUE.equals(operation.getRequestBody().getRequired())) { + required.add("body"); } } } } + return new McpSchema.JsonSchema( + "object", + properties.isEmpty() ? null : properties, + required.isEmpty() ? null : required, + false, + null, + null + ); } - protected Map extractInputSchema(Schema openApiSchema) { - if (openApiSchema == null) { - return new LinkedHashMap<>(); - } - Map jsonSchema = new LinkedHashMap<>(); + private void handleParameters(List parameters, + Map properties, + List required, + Map componentsSchemas, + Set visitedRefs) { + if (parameters != null) { + for (Parameter param : parameters) { + Schema schema = resolveRef(param.getSchema(), componentsSchemas, visitedRefs); + Map propSchema = buildSchemaRecursively(schema, componentsSchemas, visitedRefs); + + String name = param.getName(); + if (name != null && !name.isEmpty()) { + properties.put(name, propSchema); - if (openApiSchema.getType() != null){ - jsonSchema.put(CommonConstant.TYPE, openApiSchema.getType()); + if (Boolean.TRUE.equals(param.getRequired())) { + required.add(name); + } + } else { + // fallback: generate a safe name if missing + String safeName = "param_" + properties.size(); + properties.put(safeName, propSchema); + if (Boolean.TRUE.equals(param.getRequired())) { + required.add(safeName); + } + } + + // Log a warning if the location is unknown, but still include it + if (!Set.of("path", "query", "header", "cookie").contains(param.getIn())) { + LOGGER.warn("Unknown parameter location '{}', still adding '{}' to tool schema", + param.getIn(), name); + } + } } - if (openApiSchema.getDescription() != null){ - jsonSchema.put(CommonConstant.DESCRIPTION, openApiSchema.getDescription()); + } + + @SuppressWarnings("unchecked") + private void mergeAllOfProperties(Map schema, Map target) { + if (schema.containsKey("allOf")) { + List> allOfList = (List>) schema.get("allOf"); + for (Map item : allOfList) { + // Merge top-level properties + if (item.get("properties") instanceof Map props) { + props.forEach((k, v) -> target.put((String) k, v)); + } + // Merge combinators into the target map + for (String comb : new String[]{"anyOf", "oneOf", "allOf"}) { + if (item.containsKey(comb)) { + target.put(comb, item.get(comb)); + } + } + } } - if (openApiSchema.getFormat() != null){ - jsonSchema.put(CommonConstant.FORMAT, openApiSchema.getFormat()); + // Merge root-level properties if exist + if (schema.get("properties") instanceof Map props) { + props.forEach((k, v) -> target.put((String) k, v)); } - if (openApiSchema.getEnum() != null){ - jsonSchema.put(CommonConstant.ENUM, openApiSchema.getEnum()); + } + + private Map buildSchemaRecursively(Schema schema, + Map componentsSchemas, + Set visitedRefs) { + if (schema == null){ + return Map.of("type", "string"); } - if (CommonConstant.OBJECT.equals(openApiSchema.getType())) { - if (openApiSchema.getProperties() != null) { - Map nestedProperties = new LinkedHashMap<>(); - openApiSchema.getProperties().forEach((key, value) -> nestedProperties.put(key, extractInputSchema((Schema) value))); - jsonSchema.put(CommonConstant.PROPERTIES, nestedProperties); + schema = resolveRef(schema, componentsSchemas, visitedRefs); + Map result = new LinkedHashMap<>(); + + // Handle combinators + if (schema.getAllOf() != null) { + List> allOfList = new ArrayList<>(); + for (Schema s : schema.getAllOf()) { + allOfList.add(buildSchemaRecursively(s, componentsSchemas, visitedRefs)); } - if (openApiSchema.getRequired() != null) { - jsonSchema.put(CommonConstant.REQUIRED, openApiSchema.getRequired()); + result.put("allOf", allOfList); + } + if (schema.getOneOf() != null) { + List> oneOfList = new ArrayList<>(); + for (Schema s : schema.getOneOf()) { + oneOfList.add(buildSchemaRecursively(s, componentsSchemas, visitedRefs)); + } + result.put("oneOf", oneOfList); + } + if (schema.getAnyOf() != null) { + List> anyOfList = new ArrayList<>(); + for (Schema s : schema.getAnyOf()) { + anyOfList.add(buildSchemaRecursively(s, componentsSchemas, visitedRefs)); } + result.put("anyOf", anyOfList); + } + + // Primitive / object / array + String type = mapOpenApiType(schema.getType()); + result.put("type", type); + if (schema.getDescription() != null){ + result.put("description", schema.getDescription()); + } + if (schema.getEnum() != null){ + result.put("enum", schema.getEnum()); } - if (CommonConstant.ARRAY.equals(openApiSchema.getType())) { - if (openApiSchema.getItems() != null) { - jsonSchema.put(CommonConstant.ITEMS, extractInputSchema(openApiSchema.getItems())); + if ("object".equals(type)) { + Map props = new LinkedHashMap<>(); + if (schema.getProperties() != null) { + for (Map.Entry e : schema.getProperties().entrySet()) { + props.put(e.getKey(), + buildSchemaRecursively(resolveRef(e.getValue(), componentsSchemas, visitedRefs), + componentsSchemas, visitedRefs)); + } + } + // Merge allOf properties and nested combinators + mergeAllOfProperties(result, props); + + result.put("properties", props); + if (schema.getRequired() != null){ + result.put("required", new ArrayList<>(schema.getRequired())); } } - return jsonSchema; + + if ("array".equals(type) && schema.getItems() != null) { + result.put("items", buildSchemaRecursively(resolveRef(schema.getItems(), componentsSchemas, visitedRefs), + componentsSchemas, visitedRefs)); + } + + return result; } - private Map parameterMetaMap(Parameter p) { - Map paramMeta = new LinkedHashMap<>(); - paramMeta.put(CommonConstant.NAME, p.getName()); - paramMeta.put(CommonConstant.REQUIRED, Boolean.TRUE.equals(p.getRequired())); - if (p.getDescription() != null) { - paramMeta.put(CommonConstant.DESCRIPTION, p.getDescription()); + private Schema resolveRef(Schema schema, Map componentsSchemas, Set visitedRefs) { + if (schema != null && schema.get$ref() != null) { + String ref = schema.get$ref(); + if (visitedRefs.contains(ref)) { + return new Schema<>(); + } + visitedRefs.add(ref); + + String refName = ref.substring(ref.lastIndexOf('/') + 1); + Schema resolved = componentsSchemas.get(refName); + if (resolved != null) { + return resolved; + } } - if (p.getSchema() != null && p.getSchema().getType() != null) { - paramMeta.put(CommonConstant.TYPE, p.getSchema().getType()); + return schema; + } + + private String mapOpenApiType(String type) { + if (type == null){ + return "string"; } - return paramMeta; + return switch (type) { + case "integer", "number", "boolean", "array", "object" -> type; + default -> "string"; + }; } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java index 490d1bf2..f5c34459 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java @@ -8,382 +8,42 @@ import com.fasterxml.jackson.databind.JsonNode; import com.oracle.mcp.openapi.cache.McpServerCacheService; -import com.oracle.mcp.openapi.constants.CommonConstant; -import com.oracle.mcp.openapi.constants.ErrorMessage; import com.oracle.mcp.openapi.exception.McpServerToolInitializeException; import com.oracle.mcp.openapi.mapper.McpToolMapper; -import com.oracle.mcp.openapi.model.override.ToolOverride; import com.oracle.mcp.openapi.model.override.ToolOverridesConfig; -import com.oracle.mcp.openapi.util.McpServerUtil; import io.modelcontextprotocol.spec.McpSchema; -import io.swagger.models.ArrayModel; -import io.swagger.models.HttpMethod; -import io.swagger.models.Model; -import io.swagger.models.ModelImpl; -import io.swagger.models.Operation; -import io.swagger.models.Path; -import io.swagger.models.RefModel; import io.swagger.models.Swagger; -import io.swagger.models.parameters.BodyParameter; -import io.swagger.models.parameters.Parameter; -import io.swagger.models.parameters.QueryParameter; -import io.swagger.models.parameters.PathParameter; -import io.swagger.models.properties.Property; -import io.swagger.models.properties.RefProperty; -import io.swagger.models.properties.ArrayProperty; -import io.swagger.models.properties.ObjectProperty; import io.swagger.parser.SwaggerParser; +import io.swagger.v3.oas.models.OpenAPI; +import io.swagger.v3.parser.converter.SwaggerConverter; +import io.swagger.v3.parser.core.models.SwaggerParseResult; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.util.ArrayList; -import java.util.HashMap; -import java.util.HashSet; -import java.util.LinkedHashMap; import java.util.List; -import java.util.Map; -import java.util.Set; - /** * Implementation of {@link McpToolMapper} that converts Swagger 2.0 specifications * into MCP-compliant tool definitions. - *

- * This mapper reads a Swagger JSON/YAML specification, extracts paths, operations, - * parameters, and models, and builds a list of {@link McpSchema.Tool} objects. - *

- * The generated tools are also cached via {@link McpServerCacheService} for reuse. - * - * @author Joby Wilson Mathews (joby.mathews@oracle.com) */ public class SwaggerToMcpToolMapper implements McpToolMapper { private final McpServerCacheService mcpServerCacheService; private static final Logger LOGGER = LoggerFactory.getLogger(SwaggerToMcpToolMapper.class); - /** - * Creates a new {@code SwaggerToMcpToolMapper}. - * - * @param mcpServerCacheService cache service used to store generated MCP tools. - */ public SwaggerToMcpToolMapper(McpServerCacheService mcpServerCacheService) { this.mcpServerCacheService = mcpServerCacheService; } - /** - * Converts a Swagger 2.0 specification (as a Jackson {@link JsonNode}) - * into a list of MCP tools. - * - * @param swaggerJson the Swagger specification in JSON tree form. - * @return a list of {@link McpSchema.Tool} objects. - * @throws IllegalArgumentException if the specification does not contain a {@code paths} object. - */ @Override - public List convert(JsonNode swaggerJson, ToolOverridesConfig toolOverridesConfig) throws McpServerToolInitializeException { - LOGGER.debug("Parsing Swagger 2 JsonNode to Swagger object..."); - Swagger swaggerSpec = parseSwagger(swaggerJson); - if(swaggerSpec ==null){ - throw new McpServerToolInitializeException(ErrorMessage.INVALID_SWAGGER_SPEC); - } + public List convert(JsonNode swaggerNode, ToolOverridesConfig toolOverridesConfig) + throws McpServerToolInitializeException { - if (swaggerSpec.getPaths() == null || swaggerSpec.getPaths().isEmpty()) { - throw new McpServerToolInitializeException(ErrorMessage.MISSING_PATH_IN_SPEC); - } + SwaggerConverter converter = new SwaggerConverter(); + SwaggerParseResult result = converter.readContents(swaggerNode.toString(), null, null); + OpenAPI openAPI = result.getOpenAPI(); + return new OpenApiToMcpToolMapper(mcpServerCacheService).convert(openAPI,toolOverridesConfig); - List mcpTools = processPaths(swaggerSpec,toolOverridesConfig); - LOGGER.debug("Conversion complete. Total tools created: {}", mcpTools.size()); - updateToolsToCache(mcpTools); - return mcpTools; } - /** - * Processes all paths in the Swagger specification and builds corresponding MCP tools. - * - * @param swagger the parsed Swagger object. - * @param toolOverridesConfig the tool overrides configuration. - * @return list of {@link McpSchema.Tool}. - */ - private List processPaths(Swagger swagger, ToolOverridesConfig toolOverridesConfig) { - List mcpTools = new ArrayList<>(); - Set toolNames = new HashSet<>(); - if (swagger.getPaths() == null) return mcpTools; - - for (Map.Entry pathEntry : swagger.getPaths().entrySet()) { - String path = pathEntry.getKey(); - Path pathItem = pathEntry.getValue(); - if (pathItem == null){ - continue; - } - - processOperationsForPath(swagger,path, pathItem, mcpTools,toolNames,toolOverridesConfig); - } - return mcpTools; - } - - /** - * Extracts operations (GET, POST, etc.) for a given Swagger path and converts them to MCP tools. - * - * @param path the API path (e.g., {@code /users}). - * @param pathItem the Swagger path item containing operations. - * @param mcpTools the list to which new tools will be added. - * @param toolOverridesConfig the tool overrides configuration. - */ - private void processOperationsForPath(Swagger swaggerSpec, String path, Path pathItem, List mcpTools, Set toolNames, ToolOverridesConfig toolOverridesConfig) { - Map operations = pathItem.getOperationMap(); - if (operations == null){ - return; - } - - for (Map.Entry methodEntry : operations.entrySet()) { - McpSchema.Tool tool = buildToolFromOperation(swaggerSpec,path, methodEntry.getKey(), methodEntry.getValue(),toolNames,toolOverridesConfig); - if (tool != null) { - mcpTools.add(tool); - } - } - } - - /** - * Builds an MCP tool definition from a Swagger operation. - * - * @param path the API path. - * @param method the HTTP method. - * @param operation the Swagger operation metadata. - * @param toolOverridesConfig the tool overrides configuration. - * @return a constructed {@link McpSchema.Tool}, or {@code null} if the operation is invalid. - */ - private McpSchema.Tool buildToolFromOperation(Swagger swaggerSpec, String path, HttpMethod method, Operation operation, Set toolNames, ToolOverridesConfig toolOverridesConfig) { - String rawOperationId = (operation.getOperationId() != null && !operation.getOperationId().isEmpty()) - ? operation.getOperationId() - : McpServerUtil.toCamelCase(method.name() + " " + path); - - String toolName = makeUniqueName(toolNames,rawOperationId); - if(skipTool(toolName,toolOverridesConfig)){ - LOGGER.debug("Skipping tool: {} as it is in tool override file", toolName); - return null; - } - LOGGER.debug("--- Parsing Operation: {} {} (ID: {}) ---", method.name(), path, toolName); - ToolOverride toolOverride = toolOverridesConfig.getTools().getOrDefault(toolName,ToolOverride.EMPTY_TOOL_OVERRIDE); - String toolTitle = getToolTitle(operation,toolOverride,toolName); - String toolDescription = getToolDescription(operation,toolOverride); - - Map properties = new LinkedHashMap<>(); - List requiredParams = new ArrayList<>(); - - Map> pathParams = new HashMap<>(); - Map> queryParams = new HashMap<>(); - extractPathAndQueryParams(operation, pathParams, queryParams, properties, requiredParams); - extractRequestBody(swaggerSpec,operation, properties, requiredParams); - - McpSchema.JsonSchema inputSchema = new McpSchema.JsonSchema( - CommonConstant.OBJECT, - properties.isEmpty() ? null : properties, - requiredParams.isEmpty() ? null : requiredParams, - false, null, null - ); - - Map outputSchema = getOutputSchema(); - Map meta = buildMeta(method, path, operation, pathParams, queryParams); - - return McpSchema.Tool.builder() - .title(toolTitle) - .name(toolName) - .description(toolDescription) - .inputSchema(inputSchema) - .outputSchema(outputSchema) - .meta(meta) - .build(); - } - - private String getToolDescription(Operation operation, ToolOverride toolOverride) { - String overrideDescription = toolOverride.getDescription(); - if (McpServerUtil.isNotBlank(overrideDescription)) { - return overrideDescription; - } - if (McpServerUtil.isNotBlank(operation.getSummary())) { - return operation.getSummary(); - } - if (McpServerUtil.isNotBlank(operation.getDescription())) { - return operation.getDescription(); - } - return ""; - - } - - private String getToolTitle(Operation operation, ToolOverride toolOverride, String toolName) { - String overrideTitle = toolOverride.getTitle(); - if (McpServerUtil.isNotBlank(overrideTitle)) { - return overrideTitle; - } - return (operation.getSummary() != null && !operation.getSummary().isEmpty()) - ? operation.getSummary() - : toolName; - } - - private Map getOutputSchema() { - Map outputSchema = new HashMap<>(); - outputSchema.put(CommonConstant.ADDITIONAL_PROPERTIES, true); - return outputSchema; - } - - /** - * Extracts path and query parameters from a Swagger operation and adds them to the input schema. - */ - private void extractPathAndQueryParams(Operation operation, - Map> pathParams, - Map> queryParams, - Map properties, - List requiredParams) { - if (operation.getParameters() == null){ - return; - } - - for (Parameter param : operation.getParameters()) { - if (param instanceof PathParameter || param instanceof QueryParameter) { - Map paramSchema = new LinkedHashMap<>(); - paramSchema.put(CommonConstant.DESCRIPTION, param.getDescription()); - - if (param instanceof PathParameter) { - paramSchema.put(CommonConstant.TYPE, ((PathParameter) param).getType()); - pathParams.put(param.getName(), Map.of(CommonConstant.NAME, param.getName(), CommonConstant.REQUIRED, param.getRequired())); - } else { - paramSchema.put(CommonConstant.TYPE, ((QueryParameter) param).getType()); - queryParams.put(param.getName(), Map.of(CommonConstant.NAME, param.getName(), CommonConstant.REQUIRED, param.getRequired())); - } - - properties.put(param.getName(), paramSchema); - if (param.getRequired()) { - requiredParams.add(param.getName()); - } - } - } - } - - /** - * Extracts request body schema (if present) from a Swagger operation. - */ - private void extractRequestBody(Swagger swaggerSpec,Operation operation, Map properties, List requiredParams) { - if (operation.getParameters() == null){ - return; - } - - operation.getParameters().stream() - .filter(p -> p instanceof BodyParameter) - .findFirst() - .ifPresent(p -> { - BodyParameter bodyParam = (BodyParameter) p; - Model schema = bodyParam.getSchema(); - Map bodyProps = extractModelSchema(swaggerSpec,schema); - - if (!bodyProps.isEmpty()) { - properties.put(bodyParam.getName(), bodyProps); - if (bodyParam.getRequired()) { - requiredParams.add(bodyParam.getName()); - } - } - }); - } - - /** - * Recursively extracts schema details from a Swagger model definition. - */ - private Map extractModelSchema(Swagger swagger,Model model) { - if (model instanceof RefModel) { - String ref = ((RefModel) model).getSimpleRef(); - model = swagger.getDefinitions().get(ref); - } - - Map schema = new LinkedHashMap<>(); - if (model instanceof ModelImpl && model.getProperties() != null) { - Map props = new LinkedHashMap<>(); - model.getProperties().forEach((key, prop) -> - props.put(key, extractPropertySchema(swagger,prop)) - ); - schema.put(CommonConstant.TYPE, CommonConstant.OBJECT); - schema.put(CommonConstant.PROPERTIES, props); - if (((ModelImpl) model).getRequired() != null) { - schema.put(CommonConstant.REQUIRED, ((ModelImpl) model).getRequired()); - } - } else if (model instanceof ArrayModel) { - schema.put(CommonConstant.TYPE, CommonConstant.ARRAY); - schema.put(CommonConstant.ITEMS, extractPropertySchema(swagger,((ArrayModel) model).getItems())); - } - return schema; - } - - /** - * Extracts schema information from a Swagger property. - */ - private Map extractPropertySchema(Swagger swagger,Property property) { - if (property instanceof RefProperty) { - String simpleRef = ((RefProperty) property).getSimpleRef(); - Model definition = swagger.getDefinitions().get(simpleRef); - if (definition != null) { - return extractModelSchema(swagger,definition); - } else { - return Map.of("type", CommonConstant.OBJECT, CommonConstant.DESCRIPTION, "Unresolved reference: " + simpleRef); - } - } - - Map schema = new LinkedHashMap<>(); - schema.put(CommonConstant.TYPE, property.getType()); - schema.put(CommonConstant.DESCRIPTION, property.getDescription()); - - if (property instanceof ObjectProperty) { - Map props = new LinkedHashMap<>(); - ((ObjectProperty) property).getProperties().forEach((key, prop) -> - props.put(key, extractPropertySchema(swagger,prop)) - ); - schema.put(CommonConstant.PROPERTIES, props); - } else if (property instanceof ArrayProperty) { - schema.put(CommonConstant.ITEMS, extractPropertySchema(swagger,((ArrayProperty) property).getItems())); - } - return schema; - } - - /** - * Builds metadata for an MCP tool, including HTTP method, path, tags, security, and parameter maps. - */ - private Map buildMeta(HttpMethod method, String path, Operation operation, - Map> pathParams, Map> queryParams) { - Map meta = new LinkedHashMap<>(); - meta.put(CommonConstant.HTTP_METHOD, method.name()); - meta.put(CommonConstant.PATH, path); - if (operation.getTags() != null) meta.put(CommonConstant.TAGS, operation.getTags()); - if (operation.getSecurity() != null) meta.put(CommonConstant.SECURITY, operation.getSecurity()); - if (!pathParams.isEmpty()) meta.put(CommonConstant.PATH_PARAMS, pathParams); - if (!queryParams.isEmpty()) meta.put(CommonConstant.QUERY_PARAMS, queryParams); - return meta; - } - - /** - * Stores the generated tools into the cache service. - */ - private void updateToolsToCache(List tools) { - for (McpSchema.Tool tool : tools) { - mcpServerCacheService.putTool(tool.name(), tool); - } - } - - /** - * Parses the Swagger JSON into a {@link Swagger} object. - */ - private Swagger parseSwagger(JsonNode jsonNode) { - return new SwaggerParser().parse(jsonNode.toString()); - } - - /** - * Ensures a unique tool name by appending a numeric suffix if necessary. - */ - private String makeUniqueName(Set toolNAMES,String base) { - - String name = base; - int counter = 1; - while (toolNAMES.contains(name)) { - name = base + CommonConstant.UNDER_SCORE + counter++; - } - toolNAMES.add(name); - return name; - } - - } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiAuthHandler.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiAuthHandler.java index 792e0119..829e86ba 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiAuthHandler.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiAuthHandler.java @@ -8,6 +8,8 @@ import com.oracle.mcp.openapi.enums.OpenApiSchemaAuthType; import com.oracle.mcp.openapi.model.McpServerConfig; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.nio.charset.StandardCharsets; import java.util.Arrays; @@ -23,6 +25,8 @@ */ public class RestApiAuthHandler { + private static final Logger LOGGER = LoggerFactory.getLogger(RestApiAuthHandler.class); + /** * Prepares HTTP headers, including authentication headers based on server configuration. * @@ -31,7 +35,6 @@ public class RestApiAuthHandler { */ public Map extractAuthHeaders(McpServerConfig config) { Map headers = new HashMap<>(); - //headers.put("Accept", "application/json"); OpenApiSchemaAuthType authType = config.getAuthType(); if (authType == null) { diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiExecutionService.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiExecutionService.java index 74f7970e..950c905e 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiExecutionService.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiExecutionService.java @@ -17,7 +17,6 @@ import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Map; -import java.util.stream.Stream; /** * Service for executing REST API requests using Java's {@link HttpClient}. @@ -79,19 +78,24 @@ private HttpClient getHttpClient() { } /** - * Executes an HTTP request against the target URL with the specified method, - * request body, and headers. + * Executes an HTTP request using the given URL, method, optional request body, and headers. + *

+ * This method supports all standard HTTP methods. For {@code GET} and {@code DELETE}, + * the request body is ignored. For {@code POST}, {@code PUT}, and {@code PATCH}, the + * body is included in the request if provided. + *

* - * @param targetUrl the URL to call (must be a valid absolute URI) - * @param method HTTP method (GET, POST, PUT, PATCH, DELETE) - * @param body request body content, used only for POST, PUT, or PATCH methods; - * ignored for GET and DELETE - * @param headers optional request headers; may be {@code null} or empty - * @return the response body as a {@link String} - * @throws IOException if an I/O error occurs while sending or receiving - * @throws InterruptedException if the operation is interrupted while waiting + * @param targetUrl the absolute URL to send the request to (must be a valid URI) + * @param method the HTTP method (e.g., GET, POST, PUT, PATCH, DELETE) + * @param body the request body content; only used for POST, PUT, or PATCH requests; + * ignored for other methods; may be {@code null} or empty + * @param headers optional request headers; may be {@code null} or empty; headers with + * {@code null} values are skipped + * @return the HTTP response containing status, headers, and body as a {@link String} + * @throws IOException if an I/O error occurs while sending or receiving the request + * @throws InterruptedException if the operation is interrupted while waiting for a response */ - public String executeRequest(String targetUrl, String method, String body, Map headers) + public HttpResponse executeRequest(String targetUrl, String method, String body, Map headers) throws IOException, InterruptedException { HttpRequest.Builder requestBuilder = HttpRequest.newBuilder() @@ -101,9 +105,11 @@ public String executeRequest(String targetUrl, String method, String body, Map Stream.of(e.getKey(), e.getValue())) - .toArray(String[]::new)); + for (Map.Entry entry : headers.entrySet()) { + if (entry.getValue() != null) { + requestBuilder.header(entry.getKey(), entry.getValue()); + } + } } // Attach body only for methods that support it @@ -118,7 +124,7 @@ public String executeRequest(String targetUrl, String method, String body, Map response = getHttpClient().send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); - return response.body(); + return response; } /** @@ -131,6 +137,6 @@ public String executeRequest(String targetUrl, String method, String body, Map headers) throws IOException, InterruptedException { - return executeRequest(url, GET, null, headers); + return executeRequest(url, GET, null, headers).body(); } } diff --git a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutor.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutor.java index bb1d84b7..c9021b9f 100644 --- a/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutor.java +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutor.java @@ -20,6 +20,7 @@ import java.io.IOException; import java.net.URLEncoder; +import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Collections; @@ -88,7 +89,7 @@ public McpSchema.CallToolResult execute(McpSyncServerExchange exchange, McpSchem * @return the result of executing the tool */ public McpSchema.CallToolResult execute(McpSchema.CallToolRequest callRequest) { - String response; + HttpResponse response; try { McpSchema.Tool toolToExecute = mcpServerCacheService.getTool(callRequest.name()); String httpMethod = toolToExecute.meta().get("httpMethod").toString().toUpperCase(); @@ -117,9 +118,14 @@ public McpSchema.CallToolResult execute(McpSchema.CallToolRequest callRequest) { LOGGER.error("Execution failed for tool '{}': {}", callRequest.name(), e.getMessage()); throw new RuntimeException("Failed to execute tool: " + callRequest.name(), e); } + int statusCode = response.statusCode(); Map wrappedResponse = new HashMap<>(); - wrappedResponse.put("response",response); + wrappedResponse.put("response",response.body()); + boolean isSuccessful = statusCode >= 200 && statusCode < 300; + + return McpSchema.CallToolResult.builder() + .isError(!isSuccessful) .structuredContent(wrappedResponse) .build(); } diff --git a/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapperTest.java b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapperTest.java index 12b06ac8..c1247215 100644 --- a/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapperTest.java +++ b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapperTest.java @@ -6,11 +6,13 @@ */ package com.oracle.mcp.openapi.mapper.impl; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.node.ObjectNode; import com.oracle.mcp.openapi.cache.McpServerCacheService; import com.oracle.mcp.openapi.constants.ErrorMessage; import com.oracle.mcp.openapi.exception.McpServerToolInitializeException; +import com.oracle.mcp.openapi.model.override.ToolOverride; import com.oracle.mcp.openapi.model.override.ToolOverridesConfig; import io.modelcontextprotocol.spec.McpSchema; import org.junit.jupiter.api.BeforeEach; @@ -42,7 +44,7 @@ void setUp() { } @Test - void convert_ShouldReturnToolList_ForValidOpenApiJson() throws McpServerToolInitializeException { + void convert_ShouldReturnToolList_ForValidOpenApiJson1() throws McpServerToolInitializeException { // Arrange: simple OpenAPI JSON with one GET /users path ObjectNode openApiJson = objectMapper.createObjectNode(); openApiJson.put("openapi", "3.0.0"); @@ -104,11 +106,608 @@ void convert_ShouldSkipTool_WhenOverrideExists() throws McpServerToolInitializeE verify(cacheService, never()).putTool(anyString(), any(McpSchema.Tool.class)); } + @Test + void convert_ShouldReturnToolList_ForValidOpenApiJson() throws Exception { + String openApi = """ + { + "openapi": "3.0.0", + "paths": { + "/users": { + "get": { + "operationId": "getUsers", + "summary": "Fetch users" + } + } + } + } + """; + + JsonNode node = objectMapper.readTree(openApi); + List tools = mapper.convert(node, new ToolOverridesConfig()); + + assertEquals(1, tools.size()); + McpSchema.Tool tool = tools.getFirst(); + assertEquals("getUsers", tool.name()); + assertEquals("Fetch users", tool.title()); + verify(cacheService).putTool(eq("getUsers"), any(McpSchema.Tool.class)); + } + + @Test + void convert_ShouldThrow_WhenPathsMissing() { + String openApi = """ + { "openapi": "3.0.0" } + """; + + JsonNode node; + try { + node = objectMapper.readTree(openApi); + } catch (Exception e) { + throw new RuntimeException(e); + } + + McpServerToolInitializeException ex = + assertThrows(McpServerToolInitializeException.class, + () -> mapper.convert(node, new ToolOverridesConfig())); + + assertEquals(ErrorMessage.MISSING_PATH_IN_SPEC, ex.getMessage()); + } + + @Test + void convert_ShouldSkipTool_WhenExcluded() throws Exception { + String openApi = """ + { + "openapi": "3.0.0", + "paths": { + "/skip": { + "get": { "operationId": "skipTool" } + } + } + } + """; + + JsonNode node = objectMapper.readTree(openApi); + ToolOverridesConfig overrides = new ToolOverridesConfig(); + overrides.setExclude(Set.of("skipTool")); + + List tools = mapper.convert(node, overrides); + + assertTrue(tools.isEmpty()); + verify(cacheService, never()).putTool(anyString(), any(McpSchema.Tool.class)); + } + + @Test + void convert_ShouldParseParametersAndBody() throws Exception { + String openApi = """ + { + "openapi": "3.0.0", + "paths": { + "/users/{id}": { + "post": { + "operationId": "createUser", + "parameters": [ + { "name": "id", "in": "path", "required": true, "schema": { "type": "string" } }, + { "name": "active", "in": "query", "schema": { "type": "boolean" } }, + { "name": "traceId", "in": "header", "schema": { "type": "string" } }, + { "name": "session", "in": "cookie", "schema": { "type": "string" } } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "age": { "type": "integer" } + }, + "required": ["name"] + } + } + } + } + } + } + } + } + """; + + JsonNode node = objectMapper.readTree(openApi); + List tools = mapper.convert(node, new ToolOverridesConfig()); + + McpSchema.Tool tool = tools.getFirst(); + assertEquals("createUser", tool.name()); + assertNotNull(tool.inputSchema()); + Map props = tool.inputSchema().properties(); + assertTrue(props.containsKey("id")); + assertTrue(props.containsKey("active")); + assertTrue(props.containsKey("name")); + assertTrue(tool.inputSchema().required().contains("id")); + assertTrue(tool.inputSchema().required().contains("name")); + } + + @Test + void convert_ShouldHandleOneOf() throws Exception { + String openApi = """ + { + "openapi": "3.0.0", + "paths": { + "/shapes": { + "post": { + "operationId": "createShape", + "requestBody": { + "content": { + "application/json": { + "schema": { + "oneOf": [ + { "type": "object", "properties": { "circle": { "type": "number" } } }, + { "type": "object", "properties": { "square": { "type": "number" } } } + ] + } + } + } + } + } + } + } + } + """; + + JsonNode node = objectMapper.readTree(openApi); + List tools = mapper.convert(node, new ToolOverridesConfig()); + + McpSchema.Tool tool = tools.getFirst(); + McpSchema.JsonSchema schema = tool.inputSchema(); + + assertNotNull(schema); + } + + + @Test + void testPrimitiveRootTypeRequestBody() throws Exception { + String openApi = """ + { + "openapi": "3.0.0", + "paths": { + "/ping": { + "post": { + "operationId": "ping", + "requestBody": { + "content": { + "application/json": { "schema": { "type": "string" } } + }, + "required": true + } + } + } + } + } + """; + + JsonNode node = objectMapper.readTree(openApi); + List tools = mapper.convert(node, new ToolOverridesConfig()); + + McpSchema.Tool tool = tools.getFirst(); + assertEquals("ping", tool.name()); + assertTrue(tool.inputSchema().properties().containsKey("body")); + } + + @Test + void testArrayRootRequestBody() throws Exception { + String openApi = """ + { + "openapi": "3.0.0", + "paths": { + "/numbers": { + "post": { + "operationId": "postNumbers", + "requestBody": { + "content": { + "application/json": { "schema": { "type": "array", "items": { "type": "integer" } } } + } + } + } + } + } + } + """; + + JsonNode node = objectMapper.readTree(openApi); + List tools = mapper.convert(node, new ToolOverridesConfig()); + + McpSchema.Tool tool = tools.getFirst(); + Map body = (Map) tool.inputSchema().properties().get("body"); + assertEquals("array", body.get("type")); + } + + @Test + void testOneOfNestedProperty() throws Exception { + String openApi = """ + { + "openapi": "3.0.0", + "paths": { + "/shapes": { + "post": { + "operationId": "createShape", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "shape": { + "oneOf": [ + { "type": "object", "properties": { "circle": { "type": "number" } } }, + { "type": "object", "properties": { "square": { "type": "number" } } } + ] + } + } + } + } + } + } + } + } + } + } + """; + + JsonNode node = objectMapper.readTree(openApi); + List tools = mapper.convert(node, new ToolOverridesConfig()); + McpSchema.Tool tool = tools.getFirst(); + Map shapeProp = (Map) tool.inputSchema().properties().get("shape"); + assertTrue(shapeProp.containsKey("oneOf")); + } + + void testAllOfAnyOfCombination() throws Exception { + String openApi = """ + { + "openapi": "3.0.0", + "paths": { + "/complex": { + "post": { + "operationId": "complexBody", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { + "type": "object", + "properties": { "a": { "type": "string" } } + }, + { + "anyOf": [ + { "type": "object", "properties": { "b": { "type": "integer" } } }, + { "type": "object", "properties": { "c": { "type": "boolean" } } } + ] + } + ] + } + } + } + } + } + } + } + } + """; + + JsonNode node = objectMapper.readTree(openApi); + List tools = mapper.convert(node, new ToolOverridesConfig()); + McpSchema.Tool tool = tools.getFirst(); + Map schemaProps = tool.inputSchema().properties(); + assertTrue(schemaProps.containsKey("a")); + // allOf and anyOf keys might be under schema object + assertTrue(schemaProps.values().stream().anyMatch(v -> v instanceof Map && (((Map) v).containsKey("anyOf")))); + } + + @Test + void testParameterEnumAndArray() throws Exception { + String openApi = """ + { + "openapi": "3.0.0", + "paths": { + "/query": { + "get": { + "operationId": "queryParams", + "parameters": [ + { "name": "status", "in": "query", "schema": { "type": "string", "enum": ["open", "closed"] } }, + { "name": "ids", "in": "query", "schema": { "type": "array", "items": { "type": "integer" } } } + ] + } + } + } + } + """; + + JsonNode node = objectMapper.readTree(openApi); + List tools = mapper.convert(node, new ToolOverridesConfig()); + McpSchema.Tool tool = tools.getFirst(); + Map props = tool.inputSchema().properties(); + Map statusProp = (Map) props.get("status"); + Map idsProp = (Map) props.get("ids"); + assertTrue(statusProp.containsKey("enum")); + assertEquals("array", idsProp.get("type")); + } @Test - void extractInputSchema_ShouldHandleNullSchema() { - Map result = mapper.extractInputSchema(null); - assertNotNull(result); - assertTrue(result.isEmpty()); + void testToolOverridesTitleDescription() throws Exception { + String openApi = """ + { + "openapi": "3.0.0", + "paths": { + "/override": { + "get": { + "operationId": "overrideTool", + "summary": "Original summary", + "description": "Original description" + } + } + } + } + """; + + JsonNode node = objectMapper.readTree(openApi); + ToolOverridesConfig overrides = new ToolOverridesConfig(); + ToolOverride override = new ToolOverride(); + override.setTitle("Overridden Title"); + override.setDescription("Overridden Description"); + overrides.setTools(Map.of("overrideTool", override)); + + List tools = mapper.convert(node, overrides); + McpSchema.Tool tool = tools.getFirst(); + assertEquals("Overridden Title", tool.title()); + assertEquals("Overridden Description", tool.description()); + } + + @Test + void testRefResolution() throws Exception { + String openApi = """ + { + "openapi": "3.0.0", + "components": { + "schemas": { + "User": { + "type": "object", + "properties": { "name": { "type": "string" } }, + "required": ["name"] + } + } + }, + "paths": { + "/user": { + "post": { + "operationId": "createUserRef", + "requestBody": { + "content": { + "application/json": { "schema": { "$ref": "#/components/schemas/User" } } + }, + "required": true + } + } + } + } + } + """; + + JsonNode node = objectMapper.readTree(openApi); + List tools = mapper.convert(node, new ToolOverridesConfig()); + McpSchema.Tool tool = tools.getFirst(); + Map body = (Map) tool.inputSchema().properties().get("name"); + assertNotNull(body); + } + + @Test + void testEmptyPathsThrowsException() { + ObjectNode openApiJson = objectMapper.createObjectNode(); + openApiJson.put("openapi", "3.0.0"); + ToolOverridesConfig overrides = new ToolOverridesConfig(); + + McpServerToolInitializeException ex = assertThrows(McpServerToolInitializeException.class, + () -> mapper.convert(openApiJson, overrides)); + assertEquals(ErrorMessage.MISSING_PATH_IN_SPEC, ex.getMessage()); + } + + @Test + void testUnknownParameterLocationDoesNotCrash() throws Exception { + String openApi = """ + { + "openapi": "3.0.0", + "paths": { + "/weird": { + "get": { + "operationId": "weirdParam", + "parameters": [ + { "name": "unknown", "in": "body", "schema": { "type": "string" } } + ] + } + } + } + } + """; + + JsonNode node = objectMapper.readTree(openApi); + List tools = mapper.convert(node, new ToolOverridesConfig()); + assertEquals(1, tools.size()); + McpSchema.Tool tool = tools.getFirst(); + assertNull(tool.inputSchema().properties()); + } + + @Test + void testCacheUpdatedForAllTools() throws Exception { + String openApi = """ + { + "openapi": "3.0.0", + "paths": { + "/one": { "get": { "operationId": "toolOne" } }, + "/two": { "get": { "operationId": "toolTwo" } } + } + } + """; + + JsonNode node = objectMapper.readTree(openApi); + mapper.convert(node, new ToolOverridesConfig()); + + verify(cacheService).putTool(eq("toolOne"), any(McpSchema.Tool.class)); + verify(cacheService).putTool(eq("toolTwo"), any(McpSchema.Tool.class)); + } + + @Test + void testConvert_WithEmptyRequestBody_DoesNotFail() throws Exception { + String openApi = """ + { + "openapi": "3.0.0", + "paths": { + "/emptyBody": { + "post": { + "operationId": "emptyBodyTool" + } + } + } + } + """; + JsonNode node = objectMapper.readTree(openApi); + List tools = mapper.convert(node, new ToolOverridesConfig()); + + assertEquals(1, tools.size()); + McpSchema.Tool tool = tools.getFirst(); + assertNotNull(tool.inputSchema()); + assertNull(tool.inputSchema().properties()); + assertNull(tool.inputSchema().required()); + } + + @Test + void testConvert_WithNestedAllOf_ShouldMergeProperties() throws Exception { + String openApi = """ + { + "openapi": "3.0.0", + "paths": { + "/nestedAllOf": { + "post": { + "operationId": "nestedAllOfTool", + "requestBody": { + "content": { + "application/json": { + "schema": { + "allOf": [ + { "type": "object", "properties": { "a": { "type": "string" } } }, + { "type": "object", "properties": { "b": { "type": "integer" } } } + ] + } + } + } + } + } + } + } + } + """; + JsonNode node = objectMapper.readTree(openApi); + List tools = mapper.convert(node, new ToolOverridesConfig()); + + McpSchema.Tool tool = tools.getFirst(); + Map props = tool.inputSchema().properties(); + assertTrue(props.containsKey("a")); + assertTrue(props.containsKey("b")); + } + + @Test + void testConvert_WithEnumInRequestBodyArrayItems() throws Exception { + String openApi = """ + { + "openapi": "3.0.0", + "paths": { + "/arrayEnum": { + "post": { + "operationId": "arrayEnumTool", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { "type": "string", "enum": ["X", "Y"] } + } + } + } + } + } + } + } + } + """; + + JsonNode node = objectMapper.readTree(openApi); + List tools = mapper.convert(node, new ToolOverridesConfig()); + + McpSchema.Tool tool = tools.getFirst(); + Map body = (Map) tool.inputSchema().properties().get("body"); + assertEquals("array", body.get("type")); + Map items = (Map) body.get("items"); + assertEquals(List.of("X", "Y"), items.get("enum")); + } + + @Test + void testConvert_WithMultipleMethodsOnSamePath() throws Exception { + String openApi = """ + { + "openapi": "3.0.0", + "paths": { + "/multiMethod": { + "get": { "operationId": "getMulti" }, + "post": { "operationId": "postMulti" } + } + } + } + """; + + JsonNode node = objectMapper.readTree(openApi); + List tools = mapper.convert(node, new ToolOverridesConfig()); + + assertEquals(2, tools.size()); + assertTrue(tools.stream().anyMatch(t -> t.name().equals("getMulti"))); + assertTrue(tools.stream().anyMatch(t -> t.name().equals("postMulti"))); + verify(cacheService).putTool(eq("getMulti"), any(McpSchema.Tool.class)); + verify(cacheService).putTool(eq("postMulti"), any(McpSchema.Tool.class)); + } + + @Test + void testConvert_WithEmptyComponents_ShouldNotFail() throws Exception { + String openApi = """ + { + "openapi": "3.0.0", + "components": {}, + "paths": { + "/simple": { "get": { "operationId": "simpleTool" } } + } + } + """; + + JsonNode node = objectMapper.readTree(openApi); + List tools = mapper.convert(node, new ToolOverridesConfig()); + + assertEquals(1, tools.size()); + McpSchema.Tool tool = tools.getFirst(); + assertNotNull(tool.inputSchema()); + verify(cacheService).putTool(eq("simpleTool"), any(McpSchema.Tool.class)); + } + + @Test + void testConvert_WithDuplicateToolNames_ShouldGenerateUniqueNames() throws Exception { + String openApi = """ + { + "openapi": "3.0.0", + "paths": { + "/dup": { "get": { "operationId": "tool" } }, + "/dup2": { "get": { "operationId": "tool" } } + } + } + """; + + JsonNode node = objectMapper.readTree(openApi); + List tools = mapper.convert(node, new ToolOverridesConfig()); + + assertEquals(2, tools.size()); + Set names = tools.stream().map(McpSchema.Tool::name).collect(java.util.stream.Collectors.toSet()); + assertEquals(2, names.size()); // ensure uniqueness } } diff --git a/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutorTest.java b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutorTest.java index d2b34695..9a98c399 100644 --- a/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutorTest.java +++ b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutorTest.java @@ -25,16 +25,19 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.io.IOException; +import java.net.http.HttpResponse; import java.util.Collections; import java.util.HashMap; import java.util.Map; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -112,8 +115,12 @@ void execute_PostRequest_Successful() throws IOException, InterruptedException { Map headers = new HashMap<>(); headers.put("Authorization", "Bearer token"); when(restApiAuthHandler.extractAuthHeaders(serverConfig)).thenReturn(headers); + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn("{\"status\":\"success\"}"); + when(restApiExecutionService.executeRequest(anyString(), anyString(), anyString(), any())) - .thenReturn("{\"status\":\"success\"}"); + .thenReturn(mockResponse); // Act McpSchema.CallToolResult result = openApiMcpToolExecutor.execute(callRequest); @@ -147,8 +154,12 @@ void execute_GetRequest_Successful() throws IOException, InterruptedException { when(mcpServerCacheService.getTool("getUser")).thenReturn(tool); when(mcpServerCacheService.getServerConfig()).thenReturn(serverConfig); + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn("{\"id\":1}"); + when(restApiExecutionService.executeRequest(anyString(), anyString(), any(), any())) - .thenReturn("{\"id\":1}"); + .thenReturn(mockResponse); // Act openApiMcpToolExecutor.execute(callRequest); @@ -157,7 +168,7 @@ void execute_GetRequest_Successful() throws IOException, InterruptedException { verify(restApiExecutionService).executeRequest(urlCaptor.capture(), methodCaptor.capture(), bodyCaptor.capture(), any()); assertEquals("https://api.example.com/user", urlCaptor.getValue()); assertEquals("GET", methodCaptor.getValue()); - assertNull(bodyCaptor.getValue()); // No body for GET requests + assertNull(bodyCaptor.getValue()); } /** @@ -166,11 +177,13 @@ void execute_GetRequest_Successful() throws IOException, InterruptedException { @Test void execute_ApiKeyInQuery_Successful() throws IOException, InterruptedException { // Arrange - serverConfig = new McpServerConfig.Builder().apiBaseUrl("https://api.example.com") - .authType(OpenApiSchemaAuthType.API_KEY.name()) + serverConfig = new McpServerConfig.Builder() + .apiBaseUrl("https://api.example.com") + .authType(OpenApiSchemaAuthType.API_KEY.name()) .authApiKeyIn("query") - .authApiKeyName("api_key") - .authApiKey("test-secret-key".toCharArray()).build(); + .authApiKeyName("api_key") + .authApiKey("test-secret-key".toCharArray()) + .build(); McpSchema.CallToolRequest callRequest = McpSchema.CallToolRequest.builder() .name("getData") @@ -183,6 +196,14 @@ void execute_ApiKeyInQuery_Successful() throws IOException, InterruptedException when(mcpServerCacheService.getTool("getData")).thenReturn(tool); when(mcpServerCacheService.getServerConfig()).thenReturn(serverConfig); + // Mock HTTP response + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn("{\"status\":\"success\"}"); + + when(restApiExecutionService.executeRequest(anyString(), anyString(), any(), any())) + .thenReturn(mockResponse); + // Act openApiMcpToolExecutor.execute(callRequest); @@ -211,17 +232,33 @@ void execute_UrlEncoding_Successful() throws IOException, InterruptedException { meta.put(CommonConstant.PATH, "/files/{folderName}"); meta.put("pathParams", Map.of("folderName", "string")); meta.put("queryParams", Map.of("searchTerm", "string")); - McpSchema.Tool tool = McpSchema.Tool.builder().name("searchFiles").meta(meta).build(); + McpSchema.Tool tool = McpSchema.Tool.builder() + .name("searchFiles") + .meta(meta) + .build(); when(mcpServerCacheService.getTool("searchFiles")).thenReturn(tool); when(mcpServerCacheService.getServerConfig()).thenReturn(serverConfig); + // Mock HTTP response + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn("{\"result\":\"ok\"}"); + + when(restApiExecutionService.executeRequest(anyString(), anyString(), any(), any())) + .thenReturn(mockResponse); + // Act - openApiMcpToolExecutor.execute(callRequest); + McpSchema.CallToolResult result = openApiMcpToolExecutor.execute(callRequest); // Assert verify(restApiExecutionService).executeRequest(urlCaptor.capture(), anyString(), any(), any()); String expectedUrl = "https://api.example.com/files/my%20documents%2Fwork?searchTerm=a%26b%3Dc"; assertEquals(expectedUrl, urlCaptor.getValue()); + + // Verify executor processed the response correctly + assertFalse(result.isError()); + assertEquals("{\"result\":\"ok\"}", result.structuredContent().get("response")); } + } \ No newline at end of file diff --git a/src/openapi-mcp-server/src/test/resources/tools/listTool.json b/src/openapi-mcp-server/src/test/resources/tools/listTool.json index 9d7f8007..f38aaa89 100644 --- a/src/openapi-mcp-server/src/test/resources/tools/listTool.json +++ b/src/openapi-mcp-server/src/test/resources/tools/listTool.json @@ -1 +1 @@ -[{"tool":{"name":"getCompanies","title":"Get all companies","description":"Get all companies","inputSchema":{"type":"object","additionalProperties":false},"outputSchema":{"additionalProperties":true},"_meta":{"httpMethod":"GET","path":"/rest/v1/companies"}},"call":null,"callHandler":{}},{"tool":{"name":"createCompany","title":"Create a new company","description":"Create a new company","inputSchema":{"type":"object","properties":{"name":{"type":"string"},"industry":{"type":"string"},"address":{"type":"string"}},"required":["industry","name"],"additionalProperties":false},"outputSchema":{"additionalProperties":true},"_meta":{"httpMethod":"POST","path":"/rest/v1/companies"}},"call":null,"callHandler":{}},{"tool":{"name":"getCompanyById","title":"Get a company by ID","description":"Get a company by ID","inputSchema":{"type":"object","properties":{"companyId":{"type":"integer","description":"The ID of the company to retrieve."}},"required":["companyId"],"additionalProperties":false},"outputSchema":{"additionalProperties":true},"_meta":{"httpMethod":"GET","path":"/rest/v1/companies/{companyId}","pathParams":{"companyId":{"name":"companyId","required":true,"description":"The ID of the company to retrieve.","type":"integer"}}}},"call":null,"callHandler":{}},{"tool":{"name":"updateCompany","title":"Update a company","description":"Update a company","inputSchema":{"type":"object","properties":{"companyId":{"type":"integer","description":"The ID of the company to update."},"name":{"type":"string"},"industry":{"type":"string"},"address":{"type":"string"}},"required":["companyId"],"additionalProperties":false},"outputSchema":{"additionalProperties":true},"_meta":{"httpMethod":"PATCH","path":"/rest/v1/companies/{companyId}","pathParams":{"companyId":{"name":"companyId","required":true,"description":"The ID of the company to update.","type":"integer"}}}},"call":null,"callHandler":{}}] \ No newline at end of file +[{"tool":{"name":"getCompanies","title":"Get all companies","description":"Get all companies","inputSchema":{"type":"object","additionalProperties":false},"outputSchema":{"additionalProperties":true,"type":"object"},"_meta":{"httpMethod":"GET","path":"/rest/v1/companies"}},"call":null,"callHandler":{}},{"tool":{"name":"createCompany","title":"Create a new company","description":"Create a new company","inputSchema":{"type":"object","properties":{"name":{"type":"string"},"industry":{"type":"string"},"address":{"type":"string"}},"required":["industry","name"],"additionalProperties":false},"outputSchema":{"additionalProperties":true,"type":"object"},"_meta":{"httpMethod":"POST","path":"/rest/v1/companies"}},"call":null,"callHandler":{}},{"tool":{"name":"getCompanyById","title":"Get a company by ID","description":"Get a company by ID","inputSchema":{"type":"object","properties":{"companyId":{"type":"integer"}},"required":["companyId"],"additionalProperties":false},"outputSchema":{"additionalProperties":true,"type":"object"},"_meta":{"httpMethod":"GET","path":"/rest/v1/companies/{companyId}","pathParams":{"companyId":{"type":"integer","description":"The ID of the company to retrieve."}}}},"call":null,"callHandler":{}},{"tool":{"name":"updateCompany","title":"Update a company","description":"Update a company","inputSchema":{"type":"object","properties":{"companyId":{"type":"integer"},"name":{"type":"string"},"industry":{"type":"string"},"address":{"type":"string"}},"required":["companyId"],"additionalProperties":false},"outputSchema":{"additionalProperties":true,"type":"object"},"_meta":{"httpMethod":"PATCH","path":"/rest/v1/companies/{companyId}","pathParams":{"companyId":{"type":"integer","description":"The ID of the company to update."}}}},"call":null,"callHandler":{}}] \ No newline at end of file