diff --git a/src/openapi-mcp-server/.gitignore b/src/openapi-mcp-server/.gitignore new file mode 100644 index 0000000..5ff6309 --- /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/README.md b/src/openapi-mcp-server/README.md new file mode 100644 index 0000000..762c716 --- /dev/null +++ b/src/openapi-mcp-server/README.md @@ -0,0 +1,82 @@ +# OpenAPI MCP Server + +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. + +--- +## ✨ Features + +* ⚡ **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. + +--- +## 🚀 Getting Started + +### Prerequisites + +* **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. + +### Installation & Build + +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 + ``` + +--- +## 🔧 Configuration + +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 + +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": { + "my-api-server": { + "command": "java", + "args": [ + "-jar", + "/path/to/your/project/target/openapi-mcp-server-1.0.jar", + "--api-spec", + "[https://api.example.com/openapi.json](https://api.example.com/openapi.json)", + "--api-base-url", + "[https://api.example.com](https://api.example.com)" + ], + "env": { + "AUTH_TYPE": "BEARER", + "AUTH_TOKEN": "your-secret-token-here" + } + } + } +} \ 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 0000000..df7341d --- /dev/null +++ b/src/openapi-mcp-server/pom.xml @@ -0,0 +1,139 @@ + + + + 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.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.17.0 + + + + + io.modelcontextprotocol.sdk + mcp + 0.11.1 + + + + io.swagger.parser.v3 + swagger-parser + 2.1.22 + + + + org.slf4j + slf4j-api + 2.0.13 + + + + ch.qos.logback + logback-classic + 1.5.18 + + + + 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.5.2 + test + + + org.junit.jupiter + junit-jupiter + 5.10.2 + test + + + + + ${jar.finalName} + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + + + repackage + + + + + + + + + + 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 new file mode 100644 index 0000000..cfb69c3 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/OpenApiMcpServer.java @@ -0,0 +1,155 @@ +/* + * -------------------------------------------------------------------------- + * 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; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.oracle.mcp.openapi.cache.McpServerCacheService; +import com.oracle.mcp.openapi.exception.McpServerToolInitializeException; +import com.oracle.mcp.openapi.fetcher.OpenApiSchemaFetcher; +import com.oracle.mcp.openapi.model.McpServerConfig; +import com.oracle.mcp.openapi.tool.OpenApiMcpToolExecutor; +import com.oracle.mcp.openapi.tool.OpenApiMcpToolInitializer; +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.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 java.util.List; + + +/** + * Entry point for the OpenAPI MCP server. + *

+ * This Spring Boot application: + *

+ * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ +@SpringBootApplication +public class OpenApiMcpServer implements CommandLineRunner { + + @Autowired + OpenApiSchemaFetcher openApiSchemaFetcher; + + @Autowired + OpenApiMcpToolExecutor openApiMcpToolExecutor; + + @Autowired + OpenApiMcpToolInitializer openApiMcpToolInitializer; + + @Autowired + ConfigurableApplicationContext context; + + @Autowired + 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 { + McpServerConfig argument; + try { + argument = McpServerConfig.fromArgs(args); + mcpServerCacheService.putServerConfig(argument); + + // Fetch and convert OpenAPI to tools + JsonNode openApiJson = openApiSchemaFetcher.fetch(argument); + List mcpTools = openApiMcpToolInitializer.extractTools(argument,openApiJson); + + // Build MCP server capabilities + McpSchema.ServerCapabilities serverCapabilities = McpSchema.ServerCapabilities.builder() + .tools(false) + .resources(false, false) + .prompts(false) + .logging() + .completions() + .build(); + + // Use stdin/stdout for communication + 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 each tool in the server + for (McpSchema.Tool tool : mcpTools) { + SyncToolSpecification syncTool = SyncToolSpecification.builder() + .tool(tool) + .callHandler(openApiMcpToolExecutor::execute) + .build(); + mcpSyncServer.addTool(syncTool); + } + + // 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); + } + } +} 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 0000000..c777d33 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/cache/McpServerCacheService.java @@ -0,0 +1,69 @@ +/* + * -------------------------------------------------------------------------- + * 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; +import io.modelcontextprotocol.spec.McpSchema; + +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; + + /** + * 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); + } + + /** + * 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 new file mode 100644 index 0000000..2dab3bb --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/config/OpenApiMcpServerConfiguration.java @@ -0,0 +1,122 @@ +/* + * -------------------------------------------------------------------------- + * 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; +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; + +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() { + 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) { + 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 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(@Qualifier("jsonMapper") ObjectMapper jsonMapper, + @Qualifier("yamlMapper") ObjectMapper yamlMapper, + RestApiAuthHandler restApiAuthHandler) { + return new OpenApiSchemaFetcher(jsonMapper, yamlMapper, restApiAuthHandler); + } + + @Bean + public RestApiAuthHandler restApiAuthHandler(){ + return new RestApiAuthHandler(); + } + + /** + * 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,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 0000000..2be8db3 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/CommonConstant.java @@ -0,0 +1,42 @@ +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"; + String HEADER_PARAMS = "headerParams"; + String COOKIE_PARAMS = "cookieParams"; + + // 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 new file mode 100644 index 0000000..0c4e193 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/constants/ErrorMessage.java @@ -0,0 +1,31 @@ +/* + * -------------------------------------------------------------------------- + * 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."; + + 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/enums/OpenApiSchemaAuthType.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaAuthType.java new file mode 100644 index 0000000..f322eff --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaAuthType.java @@ -0,0 +1,61 @@ +/* + * -------------------------------------------------------------------------- + * 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 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()) { + return NONE; + } + try { + return OpenApiSchemaAuthType.valueOf(authType.toUpperCase()); + } catch (IllegalArgumentException ex) { + return NONE; + } + } +} 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 0000000..e6f9e6c --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaSourceType.java @@ -0,0 +1,57 @@ +/* + * -------------------------------------------------------------------------- + * 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 { + /** + * 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(); + if (specUrl != null && !specUrl.trim().isEmpty()) { + return URL; + } + + 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/enums/OpenApiSchemaType.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaType.java new file mode 100644 index 0000000..5266165 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/enums/OpenApiSchemaType.java @@ -0,0 +1,78 @@ +/* + * -------------------------------------------------------------------------- + * 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; + + /** + * 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 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 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()) { + 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/exception/McpServerToolExecutionException.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/McpServerToolExecutionException.java new file mode 100644 index 0000000..b6a6e80 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/McpServerToolExecutionException.java @@ -0,0 +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; + +/** + * 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 new file mode 100644 index 0000000..849f4e2 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/exception/McpServerToolInitializeException.java @@ -0,0 +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.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/OpenApiSchemaFetcher.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java new file mode 100644 index 0000000..1cbb806 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/fetcher/OpenApiSchemaFetcher.java @@ -0,0 +1,155 @@ +/* + * -------------------------------------------------------------------------- + * 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; +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 com.oracle.mcp.openapi.rest.RestApiAuthHandler; +import org.slf4j.Logger; +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; +import java.nio.file.Paths; +import java.util.Map; + +/** + * Utility class responsible for fetching and parsing OpenAPI schema definitions. + *

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

+ *

+ * 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 yamlMapper; + private final RestApiAuthHandler restApiAuthHandler; + + /** + * 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, RestApiAuthHandler restApiAuthHandler) { + this.jsonMapper = jsonMapper; + this.yamlMapper = yamlMapper; + this.restApiAuthHandler = restApiAuthHandler; + } + + /** + * 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; + if (type == OpenApiSchemaSourceType.URL) { + content = downloadContent(mcpServerConfig); + } 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(); + 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); + } + } + + /** + * 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) { + Map headers = restApiAuthHandler.extractAuthHeaders(mcpServerConfig); + for (Map.Entry entry : headers.entrySet()) { + conn.setRequestProperty(entry.getKey(), entry.getValue()); + } + } + + /** + * 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) { + return yamlMapper.readTree(content); + } else { + 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 new file mode 100644 index 0000000..9e8ae05 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/McpToolMapper.java @@ -0,0 +1,117 @@ +/* + * -------------------------------------------------------------------------- + * 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; +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 + * {@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 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,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 new file mode 100644 index 0000000..99f7811 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapper.java @@ -0,0 +1,484 @@ +/* + * -------------------------------------------------------------------------- + * 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; +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.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.Schema; +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; + +import java.util.ArrayList; +import java.util.Collections; +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 + * 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 McpServerCacheService mcpServerCacheService; + private static final Logger LOGGER = LoggerFactory.getLogger(OpenApiToMcpToolMapper.class); + + public OpenApiToMcpToolMapper(McpServerCacheService mcpServerCacheService) { + this.mcpServerCacheService = mcpServerCacheService; + } + + @Override + 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("No paths defined in schema."); + throw new McpServerToolInitializeException(ErrorMessage.MISSING_PATH_IN_SPEC); + } + + List mcpTools = processPaths(openAPI, toolOverridesConfig); + LOGGER.debug("Conversion complete. Total tools created: {}", mcpTools.size()); + updateToolsToCache(mcpTools); + return mcpTools; + } + + 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; + + processOperationsForPath(openAPI, path, pathItem, mcpTools, toolNames, toolOverridesConfig); + } + return mcpTools; + } + + 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(); + if (operation == null){ + continue; + } + + McpSchema.Tool tool = buildToolFromOperation(openAPI, path, method, operation, toolNames, toolOverridesConfig); + if (tool != null){ + mcpTools.add(tool); + } + } + } + + 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); + + 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 componentsSchemas = openAPI.getComponents() != null ? openAPI.getComponents().getSchemas() : new HashMap<>(); + + // Input Schema + McpSchema.JsonSchema inputSchema = getInputSchema(operation, componentsSchemas); + + // Output Schema + Map outputSchema = getOutputSchema(); + + // 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) + .name(toolName) + .description(toolDescription) + .inputSchema(inputSchema) + .outputSchema(outputSchema) + .meta(meta) + .build(); + } + + 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)){ + 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("type","object"); + outputSchema.put(CommonConstant.ADDITIONAL_PROPERTIES, true); + return outputSchema; + } + + 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){ + 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); + } + 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) { + for (McpSchema.Tool tool : tools) { + mcpServerCacheService.putTool(tool.name(), tool); + } + } + + private OpenAPI parseOpenApi(JsonNode jsonNode) { + String jsonString = jsonNode.toString(); + 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()) { + LOGGER.info("OpenAPI validation errors: {}", messages); + } + return result.getOpenAPI(); + } + + 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) { + 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 + ); + } + + + 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 (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); + } + } + } + } + + @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)); + } + } + } + } + // Merge root-level properties if exist + if (schema.get("properties") instanceof Map props) { + props.forEach((k, v) -> target.put((String) k, v)); + } + } + + private Map buildSchemaRecursively(Schema schema, + Map componentsSchemas, + Set visitedRefs) { + if (schema == null){ + return Map.of("type", "string"); + } + + 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)); + } + 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 ("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())); + } + } + + if ("array".equals(type) && schema.getItems() != null) { + result.put("items", buildSchemaRecursively(resolveRef(schema.getItems(), componentsSchemas, visitedRefs), + componentsSchemas, visitedRefs)); + } + + return result; + } + + 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; + } + } + return schema; + } + + private String mapOpenApiType(String type) { + if (type == null){ + return "string"; + } + return switch (type) { + case "integer", "number", "boolean", "array", "object" -> type; + default -> "string"; + }; + } + + +} \ 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 new file mode 100644 index 0000000..f5c3445 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/mapper/impl/SwaggerToMcpToolMapper.java @@ -0,0 +1,49 @@ +/* + * -------------------------------------------------------------------------- + * 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; +import com.oracle.mcp.openapi.cache.McpServerCacheService; +import com.oracle.mcp.openapi.exception.McpServerToolInitializeException; +import com.oracle.mcp.openapi.mapper.McpToolMapper; +import com.oracle.mcp.openapi.model.override.ToolOverridesConfig; +import io.modelcontextprotocol.spec.McpSchema; +import io.swagger.models.Swagger; +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.List; + +/** + * Implementation of {@link McpToolMapper} that converts Swagger 2.0 specifications + * into MCP-compliant tool definitions. + */ +public class SwaggerToMcpToolMapper implements McpToolMapper { + + 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 swaggerNode, ToolOverridesConfig toolOverridesConfig) + throws McpServerToolInitializeException { + + SwaggerConverter converter = new SwaggerConverter(); + SwaggerParseResult result = converter.readContents(swaggerNode.toString(), null, null); + OpenAPI openAPI = result.getOpenAPI(); + return new OpenApiToMcpToolMapper(mcpServerCacheService).convert(openAPI,toolOverridesConfig); + + } + +} 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 0000000..cb18a56 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/model/McpServerConfig.java @@ -0,0 +1,442 @@ +/* + * -------------------------------------------------------------------------- + * 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 com.oracle.mcp.openapi.model.override.ToolOverridesConfig; + +import java.util.Collections; +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. + * + * @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; + private final String apiSpec; + + // 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 final Map authCustomHeaders; + + // 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 final String toolOverridesJson; + + 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; + this.toolOverridesJson = builder.toolOverridesJson; + } + + // ----------------- 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 char[] getAuthToken() { + return authToken != null ? authToken.clone() : null; + } + + public char[] getAuthPassword() { + 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 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; + } + } + + 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; + } + } + + 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 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; + 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; + private String toolOverridesJson; + + 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 Builder toolOverridesJson(String toolOverridesJson) { + this.toolOverridesJson = toolOverridesJson; + 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 + 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); + + // 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"), "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)) { + 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'."); + } + } + + // 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"); + } + } + + // Validation for Bearer token + if ("BEARER".equalsIgnoreCase(authType)) { + if (authToken == null || authToken.length == 0) { + throw new McpServerToolInitializeException("Missing bearer token for BEARER auth"); + } + } + + // 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"); + + // 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) + .toolOverridesJson(toolOverridesJson) + .build(); + } + + // ----------------- 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 Integer getIntOrNull(String cliValue, String envVarName) { + String value = getStringValue(cliValue, envVarName); + 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<>(); + 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, ""); // Use empty string for flags without values + } + } else { + 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/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 0000000..2497fba --- /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 0000000..a069abb --- /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/rest/HttpClientManager.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/HttpClientManager.java new file mode 100644 index 0000000..1e2e18e --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/HttpClientManager.java @@ -0,0 +1,95 @@ +/* + * -------------------------------------------------------------------------- + * 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; + +import java.net.InetSocketAddress; +import java.net.ProxySelector; +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: + *

+ * + * @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); + } else { + builder.version(HttpClient.Version.HTTP_1_1); + } + } + + // Redirect policy + 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/RestApiAuthHandler.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiAuthHandler.java new file mode 100644 index 0000000..829e86b --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiAuthHandler.java @@ -0,0 +1,75 @@ +/* + * -------------------------------------------------------------------------- + * 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 org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +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 { + + private static final Logger LOGGER = LoggerFactory.getLogger(RestApiAuthHandler.class); + + /** + * 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<>(); + + 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/rest/RestApiExecutionService.java b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiExecutionService.java new file mode 100644 index 0000000..950c905 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/rest/RestApiExecutionService.java @@ -0,0 +1,142 @@ +/* + * -------------------------------------------------------------------------- + * 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; +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; + +/** + * 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; + + /** 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"; + /** 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; + } + + /** + * 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) { + return this.httpClient; + } + McpServerConfig mcpServerConfig = mcpServerCacheService.getServerConfig(); + return new HttpClientManager().getClient(mcpServerConfig); + } + + /** + * 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 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 HttpResponse 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 HTTP/1.1 + .timeout(Duration.ofSeconds(30)); + + // Add headers + if (headers != null && !headers.isEmpty()) { + for (Map.Entry entry : headers.entrySet()) { + if (entry.getValue() != null) { + requestBuilder.header(entry.getKey(), entry.getValue()); + } + } + } + + // 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 = + getHttpClient().send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8)); + + return response; + } + + /** + * 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).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 new file mode 100644 index 0000000..c9021b9 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutor.java @@ -0,0 +1,221 @@ +/* + * -------------------------------------------------------------------------- + * 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.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.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +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 + * 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; + private final RestApiAuthHandler restApiAuthHandler; + + /** + * 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, RestApiAuthHandler restApiAuthHandler) { + this.mcpServerCacheService = mcpServerCacheService; + this.restApiExecutionService = restApiExecutionService; + this.jsonMapper = jsonMapper; + this.restApiAuthHandler = restApiAuthHandler; + } + + /** + * 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) { + HttpResponse response; + try { + McpSchema.Tool toolToExecute = mcpServerCacheService.getTool(callRequest.name()); + String httpMethod = toolToExecute.meta().get("httpMethod").toString().toUpperCase(); + String path = toolToExecute.meta().get(CommonConstant.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 = restApiAuthHandler.extractAuthHeaders(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); + } + int statusCode = response.statusCode(); + Map wrappedResponse = new HashMap<>(); + wrappedResponse.put("response",response.body()); + boolean isSuccessful = statusCode >= 200 && statusCode < 300; + + + return McpSchema.CallToolResult.builder() + .isError(!isSuccessful) + .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) { + 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)); + + // 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; + } + + /** + * 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; + } + + /** + * 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 0000000..47e248d --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolInitializer.java @@ -0,0 +1,119 @@ +/* + * -------------------------------------------------------------------------- + * 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.core.JsonProcessingException; +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; +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; + +import java.util.List; + + +/** + * 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(McpServerConfig serverConfig,JsonNode openApiJson) throws McpServerToolInitializeException { + LOGGER.debug("Parsing OpenAPI JsonNode to OpenAPI object..."); + List mcpTools = parseApi(serverConfig,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(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,toolOverridesJson); + } else if (jsonNode.has(CommonConstant.SWAGGER)) { + 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 new file mode 100644 index 0000000..9956d19 --- /dev/null +++ b/src/openapi-mcp-server/src/main/java/com/oracle/mcp/openapi/util/McpServerUtil.java @@ -0,0 +1,60 @@ +/* + * -------------------------------------------------------------------------- + * 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(); + } + /** + * 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/main/resources/application.properties b/src/openapi-mcp-server/src/main/resources/application.properties new file mode 100644 index 0000000..33406d2 --- /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 0000000..478055e --- /dev/null +++ b/src/openapi-mcp-server/src/main/resources/logback.xml @@ -0,0 +1,35 @@ + + + + + + System.out + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + ${LOG_PATH:-${java.io.tmpdir}}/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 + + + + + + + + + + + + + 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 0000000..93ab56d --- /dev/null +++ b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/OpenApiMcpServerTest.java @@ -0,0 +1,180 @@ +/* + * -------------------------------------------------------------------------- + * 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; +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; +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.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", + "--api-base-url", "http://localhost:8080" + } +) +class OpenApiMcpServerTest { + + @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 companiesResponse; + private static String companyResponse; + + @BeforeAll + static void setup() throws Exception { + // Start WireMock once for all tests + WireMockServer wireMockServer = new WireMockServer( + WireMockConfiguration.options() + .port(8080) + .usingFilesUnderDirectory("src/test/resources") + ); + wireMockServer.start(); + + // Load test resources + expectedTools = readFile("src/test/resources/tools/listTool.json"); + 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 { + 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 + 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" + ); + + String response = executeTool(cacheService, "getCompanies", "{}"); + assertEquals(companiesResponse, response); + } + + @Test + void testExecuteGetOneCompany_BearerAuth() throws Exception { + McpServerCacheService cacheService = mockConfig( + McpServerConfig.builder() + .apiBaseUrl("http://localhost:8080") + .authType("BEARER") + .authToken("test-token".toCharArray()) + .build(), + "getCompanyById" + ); + + String response = executeTool(cacheService, "getCompanyById", "{\"companyId\":1}"); + assertEquals(companyResponse, 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()) + .build(), + "createCompany" + ); + + String response = executeTool(cacheService, "createCompany", + "{ \"name\": \"Test Company\", \"address\": \"123 Main St\" }"); + assertEquals(companyResponse, response); + } + + @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" + ); + + 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); + 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/mapper/McpToolMapperTest.java b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/mapper/McpToolMapperTest.java new file mode 100644 index 0000000..54c3191 --- /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 0000000..c124721 --- /dev/null +++ b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/mapper/impl/OpenApiToMcpToolMapperTest.java @@ -0,0 +1,713 @@ +/* + * -------------------------------------------------------------------------- + * 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; +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; +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_ForValidOpenApiJson1() 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 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 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/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 0000000..cdf8675 --- /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 new file mode 100644 index 0000000..79ecc50 --- /dev/null +++ b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/model/McpServerConfigTest.java @@ -0,0 +1,224 @@ +/* + * -------------------------------------------------------------------------- + * 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; +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; + +/** + * Unit tests for {@link McpServerConfig}. + * + * @author Joby Wilson Mathews (joby.mathews@oracle.com) + */ +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/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 0000000..9a98c39 --- /dev/null +++ b/src/openapi-mcp-server/src/test/java/com/oracle/mcp/openapi/tool/OpenApiMcpToolExecutorTest.java @@ -0,0 +1,264 @@ +/* + * -------------------------------------------------------------------------- + * 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.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; + +/** + * 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); + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn("{\"status\":\"success\"}"); + + when(restApiExecutionService.executeRequest(anyString(), anyString(), anyString(), any())) + .thenReturn(mockResponse); + + // 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); + HttpResponse mockResponse = mock(HttpResponse.class); + when(mockResponse.statusCode()).thenReturn(200); + when(mockResponse.body()).thenReturn("{\"id\":1}"); + + when(restApiExecutionService.executeRequest(anyString(), anyString(), any(), any())) + .thenReturn(mockResponse); + + // 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()); + } + + /** + * 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); + + // 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); + + // 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); + + // 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 + 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/__files/companies-response.json b/src/openapi-mcp-server/src/test/resources/__files/companies-response.json new file mode 100644 index 0000000..9f34329 --- /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 0000000..17a4938 --- /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 0000000..8a5b10e --- /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 0000000..c97763f --- /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 0000000..6ee192a --- /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 0000000..3d476e7 --- /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 0000000..60266e6 --- /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 0000000..a9b1761 --- /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 0000000..f38aaa8 --- /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":{"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