diff --git a/.gitignore b/.gitignore index c6d5700..7a0e91e 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,6 @@ venv.bak/ .coverage* htmlcov/ + +# Maven +**/target/ diff --git a/src/oracle-db-mcp-toolkit/DEMO.md b/src/oracle-db-mcp-toolkit/DEMO.md new file mode 100644 index 0000000..136dc76 --- /dev/null +++ b/src/oracle-db-mcp-toolkit/DEMO.md @@ -0,0 +1,131 @@ +# Oracle Database MCP Toolkit Demo + +## 1.Overview + +To test the capabilities of the Oracle Database MCP Toolkit, a demo instance of the MCP server is made available via + with the following tools activated: + +JDBC log analysis tools: + +- **`get-jdbc-stats`**: Extracts performance statistics including error counts, sent/received packets and byte counts. +- **`get-jdbc-queries`**: Retrieves all executed SQL queries with timestamps and execution times. +- **`get-jdbc-errors`**: Extracts all errors reported by both server and client. +- **`jdbc-log-comparison`**: Compares two log files for performance metrics, errors, and network information. + +RDBMS/SQLNet trace analysis Tools: + +- **`get-rdbms-errors`**: Extracts errors from RDBMS/SQLNet trace files. +- **`get-rdbms-packet-dumps`**: Extracts packet dumps for a specific connection ID. + +Custom tools (created using YAML configuration file): + +- **`hotels-by-name`**: Returns the details of a hotel given its name. The details include the capacity, rating and address. + This tool is created using the following YAML configuration file: + +```yaml +dataSources: + dev-db: + url: ${db_url} + user: ${user} + password: ${password} + +tools: + hotels-by-name: + dataSource: dev-db + description: Returns the details of a hotel given its name. The details include the capacity, rating and address. + parameters: + - name: name + type: string + description: Hotel name to search for. + statement: SELECT * FROM hotels WHERE name LIKE '%' || :name || '%' +``` + +Where `${db_url}`, `${user}` and `${password}`are environment variables. + +## 2. Requirements + +An MCP Client that support Streamable HTTP transport mode is needed, such as MCP Inspector, Cline or Claude Desktop. + +**Note**: If you're using Claude Desktop, you also need [mcp-remote](https://www.npmjs.com/package/mcp-remote). + +## 3. Setup + +The deployed instance uses `streamableHttp` transport protocol and a runtime generated `Authorization` token. + +Use the following token `3e297077-f01e-4045-a9d0-2a71e97e6dfa`. + +### MCP Inspector + +To use MCP Inspector as an MCP client, specify `streamableHttp`as transport type, `https://mcptoolkit.orcl.dev:45453/mcp` as the URL, _Via Proxy_ as Connection Type, +for Authentication, add a `Authorization` custom header with `Bearer 3e297077-f01e-4045-a9d0-2a71e97e6dfa` as value. +the final configuration should look as shown below: + +MCP Inspector config screenshot + +After checking the configuration, click the *Connect* button, and the available tools will be shown in the main section: + +MCP Inspector tools screenshot + +_Note :_ The filePath should be provided as a URL. + +### Cline + +Add or merge this configuration into `cline_mcp_settings.json`: + +```json +{ + "mcpServers": { + "Oracle Database MCP Toolkit (Demo)": { + "autoApprove": [], + "disabled": false, + "timeout": 60, + "type": "streamableHttp", + "url": "https://mcptoolkit.orcl.dev:45453/mcp", + "headers": { + "Authorization": "Bearer 3e297077-f01e-4045-a9d0-2a71e97e6dfa" + } + } + } +} +``` + +After saving the configuration file, the available tools will be shown in the *Configure* Tab of *MCP Servers* settings: + +Cline tools screenshot + +Here's an example of a prompt that trigger the `get-jdbc-queries` tool: + +Cline prompt example screenshot + +### Claude Desktop + +In order to connect the MCP server and also to provide the `Authorization` token to Claude Desktop, The [mcp-remote](https://www.npmjs.com/package/mcp-remote) is used to properly configure. +Below is an example of `claude_desktop_config.json` file: + +```json +{ + "mcpServers": { + "Oracle Database MCP Toolkit (Demo)": { + "command": "npx", + "args": [ + "-y", + "mcp-remote", + "https://mcptoolkit.orcl.dev:45453/mcp", + "--header", + "Authorization:${DEMO_TOKEN}" + ], + "env": { + "DEMO_TOKEN": "Bearer 3e297077-f01e-4045-a9d0-2a71e97e6dfa" + } + } + } +} +``` + +Upon saving the configuration file an opening Claude Desktop, you'll be to see the tools in the *Connectors* section: + +Claude Desktop tools screenshot + +Here's the result of the same prompt used to know what queries were executed : + +Claude Desktop prompt example screenshot \ No newline at end of file diff --git a/src/oracle-db-mcp-toolkit/Dockerfile b/src/oracle-db-mcp-toolkit/Dockerfile new file mode 100644 index 0000000..2034a15 --- /dev/null +++ b/src/oracle-db-mcp-toolkit/Dockerfile @@ -0,0 +1,35 @@ +# ---------- 1) Build stage ---------- +FROM container-registry.oracle.com/java/openjdk:17 AS builder + +ARG MAVEN_VERSION=3.9.11 +ARG MAVEN_BASE_URL=https://dlcdn.apache.org/maven/maven-3 + +RUN curl -fsSL \ + ${MAVEN_BASE_URL}/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz \ + -o /tmp/maven.tar.gz \ + && tar -xzf /tmp/maven.tar.gz -C /opt \ + && ln -s /opt/apache-maven-${MAVEN_VERSION} /opt/maven \ + && rm /tmp/maven.tar.gz + +ENV MAVEN_HOME=/opt/maven +ENV PATH="${MAVEN_HOME}/bin:${PATH}" + +WORKDIR /src + +COPY . . + +RUN mvn -B -q -DskipTests clean package + +# ---------- 2) Runtime stage ---------- +FROM container-registry.oracle.com/java/openjdk:17 + +RUN useradd -r -u 10001 appuser && \ + mkdir -p /app /ext && chown -R appuser:appuser /app /ext + +WORKDIR /app +USER appuser + +COPY --from=builder /src/target/oracle-db-mcp-toolkit-*.jar \ + /app/oracle-db-mcp-toolkit.jar + +ENTRYPOINT ["java", "-jar", "/app/oracle-db-mcp-toolkit.jar"] \ No newline at end of file diff --git a/src/oracle-db-mcp-toolkit/LICENSE.txt b/src/oracle-db-mcp-toolkit/LICENSE.txt new file mode 100644 index 0000000..8dc7c07 --- /dev/null +++ b/src/oracle-db-mcp-toolkit/LICENSE.txt @@ -0,0 +1,35 @@ +Copyright (c) 2025 Oracle and/or its affiliates. + +The Universal Permissive License (UPL), Version 1.0 + +Subject to the condition set forth below, permission is hereby granted to any +person obtaining a copy of this software, associated documentation and/or data +(collectively the "Software"), free of charge and under any and all copyright +rights in the Software, and any and all patent rights owned or freely +licensable by each licensor hereunder covering either (i) the unmodified +Software as contributed to or provided by such licensor, or (ii) the Larger +Works (as defined below), to deal in both + +(a) the Software, and +(b) any piece of software and/or hardware listed in the lrgrwrks.txt file if +one is included with the Software (each a "Larger Work" to which the Software +is contributed by such licensors), + +without restriction, including without limitation the rights to copy, create +derivative works of, display, perform, and distribute the Software and make, +use, sell, offer for sale, import, export, have made, and have sold the +Software and the Larger Work(s), and to sublicense the foregoing rights on +either these or other terms. + +This license is subject to the following condition: +The above copyright notice and either this complete permission notice or at +a minimum a reference to the UPL must be included in all copies or +substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/src/oracle-db-mcp-toolkit/README.md b/src/oracle-db-mcp-toolkit/README.md new file mode 100644 index 0000000..9f771d2 --- /dev/null +++ b/src/oracle-db-mcp-toolkit/README.md @@ -0,0 +1,583 @@ +# Oracle Database MCP Toolkit + +## 1. Overview + +Oracle Database MCP Toolkit is a Model Context Protocol (MCP) server that lets you: +* Define your own custom tools via a simple YAML configuration file. +* Use built-in tools: + * Analyze Oracle JDBC thin client logs and RDBMS/SQLNet trace files. + * **database-powered tools**, including **vector similarity search** and **SQL execution plan analysis**. +* Deploy locally or remotely - optionally as a container - with support for TLS and OAuth2 + +![MCP Toolkit Architecture Diagram](./images/MCPToolkitArchitectureDiagram.svg) + +--- + +## 2. Custom Tool Framework — Extending the MCP Server +The MCP server can load both database connection definitions and custom tool definitions from a YAML configuration file. +This provides a flexible and declarative way to extend the server without modifying or rebuilding the codebase. + +A YAML file may define: + +* **datasources:** — Database configuration info: + * `url`: This the JDBC URL used by the MCP server to connect to the database using the JDBC driver. + * `user`: The username to use for the database connection. + * `password`: The password to use for the database connection. + * `host` (optional): The hostname or IP address of the database server. + * `port` (optional): The port number on which the database server is listening. + * `database` (optional): The Oracle service name of the database. + +* One or more **tools** — The MCP tools: + * `dataSource` (optional): Defines the data source to be used (defaults to system properties `db.url`, `db.user` and `db.password`). + * `name`: The tool name and title, derived from the YAML key. + * `description`: A brief description of the tool. + * `parameters` (optional): A list of the parameters required for the tool. (To fill the statement's placeholders) + * `statement` The SQL statement to be executed by the tool. + +* If you add **parameters**, you can add the following fields: + * `name`: The name of the tool parameter. + * `type`: The data type to respect when the LLM fills the parameter. + * `description`: The description to know what this parameter is about. + * `required` (optional): Indicates whether the tool parameter is required. (default: false) + * All the parameter fields are being used to generate an InputSchema. + +### DataSource Resolution Logic + +When executing a tool, the MCP server determines which datasource to use based on the following rules: + +1. If the tool specifies a datasource, that datasource is used. + +2. If the tool does not specify a datasource, the server looks for a default datasource: + +* First, it checks whether a datasource was provided via system properties (db.url, db.user, db.password) (Higher priority). + +* If no system property datasource is available, it falls back to the first datasource defined in the YAML file, if present. + +3. If no datasource can be resolved and the tool requires one (e.g., SQL-based tools), the server reports a configuration error. + +This design ensures that tools always have a predictable datasource while giving you flexibility to choose how connections are provided—either inline in YAML or externally via system properties and environment variables. + +**Example `config.yaml`:** +```yaml +dataSources: + prod-db: + url: jdbc:oracle:thin:@prod-host:1521/ORCLPDB1 + user: ${user} + password: ${password} + +tools: + hotels-by-name: + dataSource: prod-db + description: Returns the details of a hotel given its name. The details include the capacity, rating and address. + parameters: + - name: name + type: string + description: Hotel name to search for. + required: false + statement: SELECT * FROM hotels WHERE name LIKE '%' || :name || '%' +``` +To enable YAML configuration, launch the server with: +```bash +java -DconfigFile=/path/to/config.yaml -jar .jar +``` + +--- + +## 3. Built-in Tools + +### 3.1. Oracle JDBC Log Analysis: + +These tools operate on Oracle JDBC thin client logs: + +- **`get-jdbc-stats`**: Extracts performance statistics including error counts, sent/received packets and byte counts. +- **`get-jdbc-queries`**: Retrieves all executed SQL queries with timestamps and execution times. +- **`get-jdbc-errors`**: Extracts all errors reported by both server and client. +- **`get-jdbc-connection-events`**: Shows connection open/close events. +- **`list-log-files-from-directory`**: List all visible files from a specified directory, which helps the user analyze multiple files with one prompt. +- **`jdbc-log-comparison`**: Compares two log files for performance metrics, errors, and network information. + +### 3.2. RDBMS/SQLNet Trace Analysis: + +These tools operate on RDBMS/SQLNet trace files: + +- **`get-rdbms-errors`**: Extracts errors from RDBMS/SQLNet trace files. +- **`get-rdbms-packet-dumps`**: Extracts packet dumps for a specific connection ID. + +### 3.3. Vector Similarity Search + +* **`similarity_search`**: Perform semantic similarity search using Oracle’s vector features (`VECTOR_EMBEDDING`, `VECTOR_DISTANCE`). + + **Inputs:** + + * `question` (string, required): Natural language query. + * `topK` (integer, optional, default: 5): Number of closest results. + * `table` (string, default: `profile_oracle`): Table containing text + vector embeddings. + * `dataColumn` (string, default: `text`): Text/CLOB column. + * `embeddingColumn` (string, default: `embedding`): Vector column. + * `modelName` (string, default: `doc_model`): Name of the DB vector model. + * `textFetchLimit` (integer, default: 4000): Max length of returned text. + + **Returns:** + + * JSON array of similar rows with scores and truncated snippets. + +### 3.4. SQL Execution Plan Analysis + +* **`explain_plan`**: Generate Oracle execution plans and receive a pre-formatted LLM prompt for tuning and explanation. + + **Modes:** + + * `static` — Uses `EXPLAIN PLAN` (estimated plan; does not run the SQL). + * `dynamic` — Uses `DBMS_XPLAN.DISPLAY_CURSOR` for the **actual** plan of a cursor. + + **Inputs:** + + * `sql` (required): SQL query to analyze. + * `mode` (static|dynamic, default: static) + * `execute` (boolean): Execute SQL to obtain a cursor in dynamic mode. + * `maxRows` (integer, default: 1): Limit rows fetched during execution. + * `xplanOptions` (string): Formatting options. + + * Default dynamic: `ALLSTATS LAST +PEEKED_BINDS +OUTLINE +PROJECTION` + * Default static: `BASIC +OUTLINE +PROJECTION +ALIAS` + + **Returns:** + + * `planText`: DBMS_XPLAN output. + * `llmPrompt`: A structured prompt for an LLM to explain + tune the plan. + +--- + +## 4. Installation + +### 4.1. Prerequisites +- **Java 17+** (JDK) +- **Credentials** with permissions for your intended operations +- **MCP client** (e.g., Claude Desktop) to call the tools + +> The server uses UCP pooling out of the box (initial/min= 1). + +### 4.2. Build the MCP server jar + +```bash +mvn clean install +``` + +The created jar can be found in `target/oracle-db-mcp-toolkit-1.0.0.jar`. + +### 4.3. Choose a transport mode (stdio vs HTTP) + +`oracle-db-mcp-toolkit` supports two transport modes: + +- **Stdio (default)** – the MCP client spawns the JVM process and talks over stdin/stdout +- **HTTP (streamable)** – the MCP server runs as an HTTP service, and clients connect via a URL + +#### 4.3.1. Stdio mode (default) + +This is the mode used by tools like Claude Desktop, where the client directly launches: + +```jsonc +{ + "mcpServers": { + "oracle-db-mcp-toolkit": { + "command": "java", + "args": [ + "-Ddb.url=jdbc:oracle:thin:@your-host:1521/your-service", + "-Ddb.user=your_user", + "-Ddb.password=your_password" + "-Dtools=get-jdbc-stats,get-jdbc-queries", + "-Dojdbc.ext.dir=/path/to/extra-jars", + "-jar", + "/oracle-db-mcp-toolkit-1.0.0.jar" + ] + } + } +} +``` +If you don’t set `-Dtransport`, the server runs in stdio mode by default. + +#### 4.3.2. HTTP mode + +In HTTP mode, you run the server as a standalone HTTP service and point an MCP client to it. + +Start the server: + +```shell +java \ + -Dtransport=http \ + -Dhttp.port=45450 \ + -Ddb.url=jdbc:oracle:thin:@your-host:1521/your-service \ + -Ddb.user=your_user \ + -Ddb.password=your_password \ + -Dtools=get-jdbc-stats,get-jdbc-queries \ + -jar /oracle-db-mcp-toolkit-1.0.0.jar +``` +This exposes the MCP endpoint at: `http://localhost:45450/mcp`. + +### 4.4. Enabling HTTPS (SSL/TLS) +To enable HTTPS (SSL/TLS), specify your certificate keystore path and password using the `-DcertificatePath` and `-DcertificatePassword` options. +Only PKCS12 (`.p12` or `.pfx`) keystore files are supported. +You can set the HTTPS port with the `-Dhttps.port` option. +##### Example +```shell +-DcertificatePath=/path/to/your-certificate.p12 -DcertificatePassword=yourPassword -Dhttps.port=443 +``` +### 4.5. Using HTTP transport and Cline +Cline supports streamable HTTP directly. Example: + +```json +{ + "mcpServers": { + "oracle-db-mcp-toolkit": { + "type": "streamableHttp", + "url": "http://localhost:45450/mcp" + } + } +} +``` + +### 4.6. Using HTTP from Claude Desktop +Claude Desktop accepts HTTPS endpoints for remote MCP servers. +If your MCP server is only available over plain HTTP (e.g. http://localhost:45450/mcp), +you can use the `mcp-remote` workaround: + +```json +{ + "mcpServers": { + "oracle-db-mcp-toolkit": { + "command": "npx", + "args": [ + "-y", + "mcp-remote", + "http://localhost:45450/mcp" + ] + } + } +} +``` + +### 4.7. HTTP Authentication Configuration + +#### 4.7.1. Generated Token (For Development and Testing) + +To enable authentication for the HTTP server, you must set the `-DenableAuthentication` system property to `true` (default value is `false`). +If it's enabled (e.g. set to `true`) the MCP Server will check if there's an environment variable called `ORACLE_DB_TOOLKIT_AUTH_TOKEN` and its value will be used as a token. +If the environment variable is not found, then a random UUID token will be generated once per JVM session. The token would be logged at the `INFO` level. + +When connecting to the MCP server, the token needs to be provided in the Authorization header of each request using the `Bearer ` prefix. + +#### 4.7.2. OAuth2 Configuration + +In order to configure an OAuth2 server, the `-DenableAuthentication` should be enabled alongside the following system properties: + +- `-DauthServer`: The OAuth2 server URL which MUST provide the `/.well-known/oauth-authorization-server`. But if the authorization server only provides the `/.well-known/openid-configuration` you can enable `-DredirectOAuthToOpenID`. +- `-DredirectOAuthToOpenID`: (default: `false`) This system property is used to as a workaround to support OAuth servers that provide `/.well-known/openid-configuration` and not `/.well-known/oauth-authorization-server`. + It works by creating an `/.well-known/oauth-authorization-server` endpoint on the MCP Server that redirects to the OAuth server's `/.well-known/openid-configuration` endpoint. +- `-DintrospectionEndpoint`: The OAuth2 server's introspection endpoint used to validate an access token (The OAuth2 introspection JSON response MUST contain the `active` field, e.g. `{...,"active": false,..}`). +- `-DclientId`: Client ID (e.g. `oracle-db-toolkit`) +- `-DclientSecret`: Client Secret (e.g. `Xj9mPqR2vL5kN8tY3hB7wF4uD6cA1eZ0`) +- `-DallowedHosts`: (default: `*`) The value of `Access-Control-Allow-Origin` header when requesting the `/.well-known/oauth-protected-resource` endpoint (and `/.well-known/oauth-authorization-server` if `-DredirectOAuthToOpenID` is set to `true`) of the MCP Server. + +For more details regarding this MCP and OAuth, please see [MCP specification for authorization](https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization) (or a newer version if available). + +##### Examples + +###### Enabling Authentication with OAuth2 + +```bash +java \ + -Ddb.url=jdbc:oracle:thin:@host:1521/service \ + -Dtransport=http \ + -Dhttp.port=45450 \ + -DenableAuthentication=true \ + -DauthServer=http://localhost:8080/realms/mcp \ + -DintrospectionEndpoint=http://localhost:8080/realms/mcp/protocol/openid-connect/token/introspect \ + -DclientId=oracle-db-toolkit \ + -DclientSecret=Xj9mPqR2vL5kN8tY3hB7wF4uD6cA1eZ0 \ + -DallowedHosts=http://localhost:6274 \ + -jar /oracle-db-mcp-toolkit-1.0.0.jar +``` + +In the above example, we configured OAuth2 with a local KeyCloak server with a realm named `mcp`, and we only allowed a local [MCP Inspector](https://modelcontextprotocol.io/docs/tools/inspector) +running at to retrieve the data from + + +##### Enabling Authentication without OAuth2 + +_Note: This mode is used only for development and testing purposes._ + +```bash +java \ + -Ddb.url=jdbc:oracle:thin:@host:1521/service \ + -Dtransport=http \ + -Dhttp.port=45450 \ + -DenableAuthentication=true \ + -jar /oracle-db-mcp-toolkit-1.0.0.jar +``` +After starting the server, a UUID token will be generated and logged at INFO level: + +```log +Nov 25, 2025 3:30:46 PM com.oracle.database.jdbc.oauth.OAuth2Configuration +INFO: Authentication is enabled +Nov 25, 2025 3:30:46 PM com.oracle.database.jdbc.oauth.OAuth2Configuration +WARNING: OAuth2 is not configured +Nov 25, 2025 3:30:46 PM com.oracle.database.jdbc.oauth.TokenGenerator +INFO: Authorization token generated (for testing and development use only): 0dd11948-37a3-470f-911e-4cd8b3d6f69c +Nov 25, 2025 3:30:46 PM com.oracle.database.jdbc.OracleDatabaseMCPToolkit startHttpServer +INFO: [oracle-db-mcp-toolkit] HTTP transport started on http://localhost:45450 (endpoint: http://localhost:45450/mcp) +``` + +If `ORACLE_DB_TOOLKIT_AUTH_TOKEN` environment variable is set: + +```bash +export ORACLE_DB_TOOLKIT_AUTH_TOKEN=Secret_DeV_T0ken +``` + +Then the server logs will be the following: + +```log +Nov 25, 2025 4:10:26 PM com.oracle.database.jdbc.oauth.OAuth2Configuration +INFO: Authentication is enabled +Nov 25, 2025 4:10:26 PM com.oracle.database.jdbc.oauth.OAuth2Configuration +WARNING: OAuth2 is not configured +Nov 25, 2025 4:10:26 PM com.oracle.database.jdbc.oauth.TokenGenerator +INFO: Authorization token generated (for testing and development use only): Secret_DeV_T0ken +``` + +Ultimately, the token must be included in the http request header (e.g. `Authorization: Bearer 0dd11948-37a3-470f-911e-4cd8b3d6f69c` or `Authorization: Bearer Secret_DeV_T0ken`). + +--- + +## 5. Supported System Properties + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
PropertyRequiredDescriptionExample
db.urlNo*JDBC URL for Oracle Database. Required only if any database tools are enabled (not required for log-analyzer–only setups).jdbc:oracle:thin:@your-host:1521/your-service
db.userNo*Database username (not required if using token-based auth or centralized config loaded via ojdbc.ext.dir)ADMIN or your-username
db.passwordNo*Database password (not required if using token-based auth or centralized config loaded via ojdbc.ext.dir)your-secure-password
tools (aka -Dtools)No + Comma-separated allow-list of tool names to enable. + Use * or all to enable everything. + If omitted, all tools are enabled by default. + get-jdbc-stats,get-jdbc-queries
ojdbc.ext.dirNo + Directory to load extra jars at runtime (keeps the MCP jar lean). + Useful for optional components like oraclepki when using TCPS wallets, token authentication, or centralized driver config. + /opt/oracle/ext-jars
transportNo + Transport mode for the MCP server. Supported values: + stdio or http. If omitted, stdio is used. + http
http.portNo + TCP port used when -Dtransport=http is set. + 45450
https.portNo + TCP port used for SSL connection. + 45451
certificatePathNo + Path to SSL certificate keystore (Support PKCS12) + /path/to/your/certificate
certificatePasswordNo + Password of SSL certificate keystore +
configFileNoPath to a YAML file defining datasources and tools./opt/mcp/config.yaml
enableAuthenticationNoWhether HTTP authentication is required or not (default false).
+ All the subsequent OAuth2 system properties are ignored if this property is set to false.
-DenableAuthentication=true
authServerNoConfigure the OAuth2 server URL-DauthServer=http://localhost:8080/realms/master
introspectionEndpointNoThe OAuth2 server endpoint used to validate and obtain metadata about an access token.-DintrospectionEndpoint=http://localhost:8080/realms/mcp/protocol/openid-connect/token/introspect
clientIdNoThe client identifier for registering with the configured OAuth2 server.-DclientId=oracle-db-toolkit
clientSecretNoThe confidential key used to authenticate the client to the configured authorization server during the OAuth2 flow.-DclientSecret=Xj9mPqR2vL5kN8tY3hB7wF4uD6cA1eZ0
allowedHostsNoThe Access-Control-Allow-Origin header value when making a request to the MCP Server's /.well-known/oauth-protected-resource endpoint (default * e.g. all hosts are allowed).-DallowedHosts=http://localhost:6274
redirectOAuthToOpenIDNoSystem property that redirects MCP Server's /.well-known/oauth-authorization-server endpoint to the OAuth server's /.well-known/openid-configuration as a workaround for servers lacking the former (default value is false. If OAuth is not properly configured, then this system property is ignored).-DredirectOAuthToOpenID=false
+ +* Note: If you don’t set tools, all tools are available by default. + +* Conditional requirement: db.url is required **only if** any database tool is enabled via -Dtools. + +If you enable **only** the Log Analyzer tools, you can omit db.url. + +* Note: If you’re using token-based authentication (e.g., IAM tokens) or a centralized configuration provided via the JARs you place in `-Dojdbc.ext.dir`, +you can omit `db.user` and `db.password`. The driver will pick up credentials and security settings from those extensions. + +--- + +## 6. Docker Image + +A `Dockerfile` is included at the root of the project so you can build and run the MCP server as a container. + +### 6.1. Build the image +From the project root (where the Dockerfile lives): + +```bash +podman build -t oracle-db-mcp-toolkit:1.0.0 . +``` +### 6.2. Run the container (HTTP mode example) +This example runs the MCP server over HTTP and HTTPS inside the container and exposes it on port 45450 and 45451 on your host. + +```bash +podman run --rm \ + -p 45450:45450 \ + -p 45451:45451 \ + -v /path/to/certificate:/app/certif.p12:ro,z \ + -e JAVA_TOOL_OPTIONS="\ + -Dtransport=http \ + -Dhttp.port=45450 \ + -Dhttps.port=45451 \ + -DcertificatePath=[path/to/certificate] \ + -DcertificatePassword=[password] \ + -Dtools=get-jdbc-stats,get-jdbc-queries \ + -Ddb.url=jdbc:oracle:thin:@your-host:1521/your-service \ + -Ddb.user=your_user \ + -Ddb.password=your_password" \ + oracle-db-mcp-toolkit:1.0.0 +``` +This exposes the MCP endpoint at: http://[your-ip-address]:45450/mcp or https://[your-ip-address]:45451/mcp + +You can then configure Cline or Claude Desktop as described in the Using HTTP from Cline / Claude Desktop sections above. + +If you need extra JDBC / security jars (e.g. `oraclepki`, wallets, centralized config, or providers that fetch full +database credentials such as username, password, and connection string from a vault secret), +mount them and point `ojdbc.ext.dir` at that directory: + +```bash +podman run --rm \ + -p 45450:45450 \ + -p 45451:45451 \ + -v /path/to/ext:/ext:ro \ + -v /path/to/certificate:/app/certif.p12:ro,z \ + -e JAVA_TOOL_OPTIONS="\ + -Dtransport=http \ + -Dhttp.port=45450 \ + -Dhttps.port=45451 \ + -Dtools=get-jdbc-stats,get-jdbc-queries \ + -Ddb.url=jdbc:oracle:thin:@your-host:1521/your-service \ + -Ddb.user=your_user \ + -Ddb.password=your_password \ + -Dojdbc.ext.dir=/ext" \ + oracle-db-mcp-toolkit:1.0.0 +``` + +### 6.3. Using Docker/Podman with stdio +Instead of running the MCP server over HTTP, you can keep using the **stdio** transport +and let your MCP client spawn the container (via **podman run**) instead of spawning java directly. +In this mode, the MCP client talks to the server over stdin/stdout, just like with a local JAR. + +#### Example: Claude Desktop using Podman (stdio) +In this configuration, Claude Desktop runs `podman run --rm -i ... and connects to the server via stdio: + +```json +{ + "mcpServers": { + "oracle-db-mcp-toolkit": { + "command": "podman", + "args": [ + "run", + "--rm", + "-i", + "-v", "/absolute/path/to/ext:/ext:ro", + "-e", + "JAVA_TOOL_OPTIONS=-Dtools=get-jdbc-stats,get-jdbc-queries -Ddb.url=jdbc:oracle:thin:@your-host:1521/your-service -Ddb.user=your_user -Ddb.password=your_password -Dojdbc.ext.dir=/ext -DconfigFile=/config/config.yaml", + "oracle-db-mcp-toolkit:1.0.0" + ] + } + } +} +``` diff --git a/src/oracle-db-mcp-toolkit/images/MCPToolkitArchitectureDiagram.svg b/src/oracle-db-mcp-toolkit/images/MCPToolkitArchitectureDiagram.svg new file mode 100644 index 0000000..2fbb2ee --- /dev/null +++ b/src/oracle-db-mcp-toolkit/images/MCPToolkitArchitectureDiagram.svg @@ -0,0 +1,4 @@ + + + +
📄 YAML Config File
(Oracle data sources, custom tools,
env variables)
🔐 SSL Certificate (.p12)
(Used for HTTPS)
🖥️ STDIO Mode
• Command + Args
🌐 StreamableHTTP Mode
• URL + (optional OAuth)
🔀 Transport Layer
• STDIO Handler
• HTTP/HTTPS Handler
🧩 Tooling Engine
• Built-in Tools
• Custom Tools
• YAML-defined Sources
STDIOHTTP/HTTPSConfiguration LoadSSL/HTTPS Setup
MCP Client
MCP Server
Authentication Server
Authenticate
\ No newline at end of file diff --git a/src/oracle-db-mcp-toolkit/pom.xml b/src/oracle-db-mcp-toolkit/pom.xml new file mode 100644 index 0000000..9191e51 --- /dev/null +++ b/src/oracle-db-mcp-toolkit/pom.xml @@ -0,0 +1,120 @@ + + + 4.0.0 + + com.oracle.database.mcptoolkit + oracle-db-mcp-toolkit + 1.0.0 + jar + Oracle Database MCP Toolkit + The Oracle Database MCP Toolkit is a Model Context Protocol (MCP) server that + enables users to define custom tools through a YAML configuration file, analyze Oracle + JDBC thin client logs and RDBMS/SQLNet trace files using built-in tools. + It also offers database-powered tools, such as vector similarity search and SQL execution + plan analysis. + https://github.com/oracle/mcp/tree/main/src/oracle-db-mcp-toolkit + + + + The Universal Permissive License (UPL), Version 1.0 + https://oss.oracle.com/licenses/upl/ + + + + + + Oracle Corporation + https://www.oracle.com + + + + + 17 + 0.12.1 + 23.9.0.25.07 + 2.5 + 1.0.0 + 11.0.14 + 5.10.0 + ${java.version} + ${java.version} + UTF-8 + UTF-8 + + + + + io.modelcontextprotocol.sdk + mcp + ${mcp.version} + + + + com.oracle.database.jdbc + ojdbc17 + ${ojdbc.version} + + + com.oracle.database.jdbc + ucp17 + ${ojdbc.version} + + + + org.yaml + snakeyaml + ${snakeYaml.version} + + + + + com.oracle.database.jdbc + ojdbc-log-analyzer + ${logAnalyzer.version} + + + + + org.apache.tomcat.embed + tomcat-embed-core + ${tomcat.version} + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.1 + + + package + + shade + + + false + + + com.oracle.database.mcptoolkit.OracleDatabaseMCPToolkit + + + + + + + + + + + \ No newline at end of file diff --git a/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/EnvSubstitutor.java b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/EnvSubstitutor.java new file mode 100644 index 0000000..fd9944b --- /dev/null +++ b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/EnvSubstitutor.java @@ -0,0 +1,77 @@ +/* + ** Oracle Database MCP Toolkit version 1.0.0 + ** + ** Copyright (c) 2025 Oracle and/or its affiliates. + ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ + */ + +package com.oracle.database.mcptoolkit; + +import java.util.Arrays; +import java.util.regex.*; + +/** + * The EnvSubstitutor class provides a method for substituting environment variables in a given string. + * It replaces placeholders in the format ${VARIABLE_NAME} with the corresponding environment variable values. + */ +public class EnvSubstitutor { + /** + * Pattern used to match placeholders in the input string. + * The pattern matches strings in the format ${VARIABLE_NAME}. + */ + private static final Pattern PLACEHOLDER = Pattern.compile("\\$\\{([^}]+)}"); + + /** + * Substitutes environment variables in the given input string. + * + * @param input the input string containing placeholders for environment variables + * @return the input string with environment variables substituted + * @throws IllegalStateException if an environment variable is not set + */ + public static String substituteEnvVars(String input) { + if (input == null) return null; + Matcher m = PLACEHOLDER.matcher(input); + StringBuffer sb = new StringBuffer(); + while (m.find()) { + String var = m.group(1); + String value = System.getenv(var); + if (value == null) { + throw new IllegalStateException("Missing environment variable for: " + var); + } + m.appendReplacement(sb, Matcher.quoteReplacement(value)); + } + m.appendTail(sb); + return sb.toString(); + } + + public static char[] substituteEnvVarsInCharArray(char[] input) { + if (input == null) return null; + StringBuilder output = new StringBuilder(input.length * 2); // Maybe bigger due to expansions + for (int i = 0; i < input.length; ) { + if (input[i] == '$' && (i + 1) < input.length && input[i + 1] == '{') { + int end = i + 2; + while (end < input.length && input[end] != '}') { + end++; + } + if (end < input.length) { + // Extract variable name + String varName = new String(input, i + 2, end - (i + 2)); + String value = System.getenv(varName); + if (value == null) { + throw new IllegalStateException("Missing environment variable for: " + varName); + } + output.append(value); + i = end + 1; + continue; + } + } + output.append(input[i]); + i++; + } + char[] result = new char[output.length()]; + output.getChars(0, output.length(), result, 0); + // Clear input + Arrays.fill(input, '\0'); + return result; + } +} \ No newline at end of file diff --git a/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/LoadedConstants.java b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/LoadedConstants.java new file mode 100644 index 0000000..4cc309f --- /dev/null +++ b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/LoadedConstants.java @@ -0,0 +1,53 @@ +/* + ** Oracle Database MCP Toolkit version 1.0.0 + ** + ** Copyright (c) 2025 Oracle and/or its affiliates. + ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ + */ + +package com.oracle.database.mcptoolkit; + +/** + * Provides a set of constants loaded from system properties and environment variables. + * These constants are used to configure various aspects of the application, including + * network settings, tool configurations, OAuth settings, and more. + * + *

This class is not intended to be instantiated and provides only static constants. + */ +public final class LoadedConstants { + private LoadedConstants() {} // Prevent instantiation + + /** Network config */ + public static final String TRANSPORT_KIND = System.getProperty("transport", "stdio") + .trim() + .toLowerCase(); + public static final String HTTP_PORT = System.getProperty("http.port"); + public static final String HTTPS_PORT = System.getProperty("https.port"); + public static final String KEYSTORE_PATH = System.getProperty("certificatePath"); + public static final String KEYSTORE_PASSWORD = System.getProperty("certificatePassword"); + + /** Tools config */ + public static final String TOOLS = System.getProperty("tools"); + public static final String DB_URL = System.getProperty("db.url"); + public static final String DB_USER = System.getProperty("db.user"); + public static final char[] DB_PASSWORD = System.getProperty("db.password") != null + ? System.getProperty("db.password").toCharArray() + : null; + + /** OAuth config */ + public static final String ALLOWED_HOSTS= System.getProperty("allowedHosts","*"); + public static final String REDIRECT_OPENID_TO_OAUTH= System.getProperty("redirectOpenIDToOAuth","false"); + public static final boolean ENABLE_AUTH = Boolean.parseBoolean(System.getProperty("enableAuthentication","false")); + public static final String ORACLE_DB_TOOLKIT_AUTH_TOKEN = System.getenv("ORACLE_DB_TOOLKIT_AUTH_TOKEN"); + public static final String AUTH_SERVER = System.getProperty("authServer"); + public static final String INTROSPECTION_ENDPOINT = System.getProperty("introspectionEndpoint"); + public static final String CLIENT_ID = System.getProperty("clientId"); + public static final String CLIENT_SECRET = System.getProperty("clientSecret"); + + /** Yaml config */ + public static final String CONFIG_FILE = System.getProperty("configFile"); + + /** External extensions */ + public static final String OJDBC_EXT_DIR = System.getProperty("ojdbc.ext.dir"); + +} diff --git a/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/OracleDatabaseMCPToolkit.java b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/OracleDatabaseMCPToolkit.java new file mode 100644 index 0000000..df1eeb6 --- /dev/null +++ b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/OracleDatabaseMCPToolkit.java @@ -0,0 +1,196 @@ +/* + ** Oracle Database MCP Toolkit version 1.0.0 + ** + ** Copyright (c) 2025 Oracle and/or its affiliates. + ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ + */ + +package com.oracle.database.mcptoolkit; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.oracle.database.mcptoolkit.oauth.OAuth2Configuration; +import com.oracle.database.mcptoolkit.web.AuthorizationFilter; +import com.oracle.database.mcptoolkit.web.RedirectOAuthToOpenIDServlet; +import com.oracle.database.mcptoolkit.web.WebUtils; +import com.oracle.database.mcptoolkit.web.WellKnownServlet; +import io.modelcontextprotocol.server.McpServer; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.server.transport.HttpServletStreamableServerTransportProvider; +import io.modelcontextprotocol.server.transport.StdioServerTransportProvider; +import io.modelcontextprotocol.spec.McpSchema; + +import org.apache.catalina.Context; +import org.apache.catalina.startup.Tomcat; +import org.apache.tomcat.util.descriptor.web.FilterDef; +import org.apache.tomcat.util.descriptor.web.FilterMap; +import org.apache.catalina.connector.Connector; +import org.apache.tomcat.util.net.SSLHostConfig; +import org.apache.tomcat.util.net.SSLHostConfigCertificate; + +import java.io.File; +import java.util.logging.Logger; + +import static com.oracle.database.mcptoolkit.Utils.installExternalExtensionsFromDir; + +/** + * The OracleDatabaseMCPToolkit class provides the main entry point for the MCP server. + * It initializes the configuration, sets up the transport layer, and starts the MCP server. + */ +public class OracleDatabaseMCPToolkit { + private static final Logger LOG = Logger.getLogger(OracleDatabaseMCPToolkit.class.getName()); + + static ServerConfig config; + + static { + config = Utils.loadConfig(); + } + + public static void main(String[] args) { + installExternalExtensionsFromDir(); + + McpSyncServer server; + + switch (LoadedConstants.TRANSPORT_KIND) { + case "http" -> { + server = startHttpServer(); + } + case "stdio" -> { + server = McpServer + .sync(new StdioServerTransportProvider(new ObjectMapper())) + .serverInfo("oracle-db-mcp-toolkit", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder() + .tools(true) + .logging() + .build()) + .immediateExecution(true) + .build(); + } + default -> throw new IllegalArgumentException( + "Unsupported transport: " + LoadedConstants.TRANSPORT_KIND + " (expected 'stdio' or 'http')"); + } + Utils.addSyncToolSpecifications(server, config); + } + + private OracleDatabaseMCPToolkit() { + // Prevent instantiation + } + + /** + * Start HTTP Streamable MCP transport on /mcp using Tomcat. + */ + private static McpSyncServer startHttpServer() { + try { + HttpServletStreamableServerTransportProvider transport = + HttpServletStreamableServerTransportProvider.builder() + .objectMapper(new ObjectMapper()) + .mcpEndpoint("/mcp") + .build(); + + McpSyncServer server = McpServer + .sync(transport) + .serverInfo("oracle-db-mcp-toolkit", "1.0.0") + .capabilities(McpSchema.ServerCapabilities.builder() + .tools(true) + .logging() + .build()) + .immediateExecution(true) + .build(); + + Tomcat tomcat = new Tomcat(); + if(LoadedConstants.HTTP_PORT!=null){ + tomcat.setPort(Integer.parseInt(LoadedConstants.HTTP_PORT)); + tomcat.getConnector(); + } else { + tomcat.setPort(-1); + LOG.warning("Http setup is skipped: http port is not specified"); + } + + + String ctxPath = ""; + String docBase = new File(".").getAbsolutePath(); + Context ctx = tomcat.addContext(ctxPath, docBase); + + Tomcat.addServlet(ctx, "mcpServlet", transport); + ctx.addServletMappingDecoded("/mcp/*", "mcpServlet"); + + Tomcat.addServlet(ctx, "wellKnownServlet", new WellKnownServlet()); + ctx.addServletMappingDecoded( + "/.well-known/oauth-protected-resource", "wellKnownServlet"); + + if (OAuth2Configuration.getInstance().isOAuth2Configured() && WebUtils.isRedirectOpenIDToOAuthEnabled()) { + Tomcat.addServlet(ctx, "redirectOAuthToOpenIDServlet", new RedirectOAuthToOpenIDServlet()); + ctx.addServletMappingDecoded("/.well-known/oauth-authorization-server", "redirectOAuthToOpenIDServlet"); + } + + FilterDef filterDef = new FilterDef(); + filterDef.setFilterName("authFilter"); + filterDef.setFilter(new AuthorizationFilter()); + filterDef.setFilterClass(AuthorizationFilter.class.getName()); + ctx.addFilterDef(filterDef); + + FilterMap filterMap = new FilterMap(); + filterMap.setFilterName("authFilter"); + filterMap.addURLPattern("/mcp/*"); + ctx.addFilterMap(filterMap); + + if(LoadedConstants.HTTPS_PORT!=null && LoadedConstants.KEYSTORE_PATH!=null && LoadedConstants.KEYSTORE_PASSWORD != null) { + enableHttps(tomcat, LoadedConstants.KEYSTORE_PATH, LoadedConstants.KEYSTORE_PASSWORD); + } + else LOG.warning("SSL setup is skipped: Https port or Keystore path or password not specified"); + + tomcat.start(); + + LOG.info(() -> "[oracle-db-mcp-toolkit] HTTP transport started on " + LoadedConstants.HTTP_PORT + " (endpoint: /mcp)"); + + return server; + } catch (Exception e) { + throw new RuntimeException("Failed to start HTTP/streamable server", e); + } + } + + /** + * Configures and enables HTTPS on the provided Tomcat server using the specified keystore. + * + * @param tomcat the Tomcat server instance to configure + * @param keystorePath the file path to the PKCS12 keystore containing the SSL certificate + * @param keystorePassword the password for the keystore + * @throws RuntimeException if the HTTPS connector or SSL configuration fails + */ + private static void enableHttps(Tomcat tomcat, String keystorePath, String keystorePassword) { + try { + // Create HTTPS connector + Connector https = new Connector("org.apache.coyote.http11.Http11NioProtocol"); + https.setPort(Integer.parseInt(LoadedConstants.HTTPS_PORT)); + https.setSecure(true); + https.setScheme("https"); + https.setProperty("SSLEnabled", "true"); + + // Create SSL config + SSLHostConfig sslHostConfig = new SSLHostConfig(); + sslHostConfig.setHostName("_default_"); + + SSLHostConfigCertificate cert = new SSLHostConfigCertificate( + sslHostConfig, + SSLHostConfigCertificate.Type.RSA + ); + + cert.setCertificateKeystoreFile(keystorePath); + cert.setCertificateKeystorePassword(keystorePassword); + cert.setCertificateKeystoreType("PKCS12"); + + // Attach certificate to SSL config + sslHostConfig.addCertificate(cert); + + // Enable SSL + https.addSslHostConfig(sslHostConfig); + + // Register connector + tomcat.getService().addConnector(https); + + } catch (Exception e) { + throw new RuntimeException("Failed to enable HTTPS on Tomcat", e); + } + } + + +} diff --git a/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/ServerConfig.java b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/ServerConfig.java new file mode 100644 index 0000000..98d91cd --- /dev/null +++ b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/ServerConfig.java @@ -0,0 +1,185 @@ +/* + ** Oracle Database MCP Toolkit version 1.0.0 + ** + ** Copyright (c) 2025 Oracle and/or its affiliates. + ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ + */ + +package com.oracle.database.mcptoolkit; + +import com.oracle.database.mcptoolkit.config.ConfigRoot; +import com.oracle.database.mcptoolkit.config.DataSourceConfig; +import com.oracle.database.mcptoolkit.config.ToolConfig; + +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Map; +import java.util.Set; + +/** + * Immutable server configuration loaded from system properties. + * + *

Conditionally required property:

+ *
    + *
  • {@code db.url} — required only when any database tool is enabled.
  • + *
+ * + *

Optional properties:

+ *
    + *
  • {@code db.user}
  • + *
  • {@code db.password}
  • + *
  • {@code tools} — comma-separated allow-list of tool names; {@code *} or {@code all} enables all.
  • + *
+ * + *

Use {@link #fromSystemProperties()} to create an instance with validation and defaults.

+ */ +public final class ServerConfig { + public final String dbUrl; + public final String dbUser; + public final char[] dbPassword; + public final Set toolsFilter; + public final Map sources; + public final Map tools; + public static String defaultSourceName; // Only if the default db info are from yaml config to avoid redundancy + + private ServerConfig( + String dbUrl, + String dbUser, + char[] dbPassword, + Set toolsFilter, + Map sources, + Map tools + ) { + this.dbUrl = dbUrl; + this.dbUser = dbUser; + this.dbPassword = dbPassword; + this.toolsFilter = toolsFilter; + this.sources = sources; + this.tools = tools; + } + + private static final Set DB_TOOLS = Set.of( + "similarity_search", "explain_plan" + ); + + + /** + * Builds a {@link ServerConfig} from JVM system properties (i.e., {@code -Dkey=value}), + * with fallback to values from a parsed YAML configuration. + *

+ * Resolution order for each property: + *

    + *
  1. JVM system property (highest priority, e.g., {@code -Ddb.url}, {@code -Ddb.user}, {@code -Ddb.password})
  2. + *
  3. YAML config ({@link ConfigRoot} and specified source), if system property is absent or blank
  4. + *
+ *

+ * Validates that all required values are present if any database tool is enabled. + * + * @param configRoot the parsed YAML configuration root (nullable if not used) + * @param defaultSourceKey the source key in YAML to use for Oracle connection details fallback + * @return a validated configuration + * @throws IllegalStateException if required properties are missing from both system properties and YAML config + */ + public static ServerConfig fromSystemPropertiesAndYaml(ConfigRoot configRoot, String defaultSourceKey) { + Set tools = parseToolsProp(LoadedConstants.TOOLS); + boolean needDb = wantsAnyDbTools(tools); + + String dbUrl = LoadedConstants.DB_URL; + String dbUser = LoadedConstants.DB_USER; + char[] dbPass = LoadedConstants.DB_PASSWORD; + + Map sources = configRoot != null ? configRoot.dataSources : Collections.emptyMap(); + Map toolsMap = configRoot != null ? configRoot.tools : Collections.emptyMap(); + + if (toolsMap != null) { + for (Map.Entry entry : toolsMap.entrySet()) { + entry.getValue().name = entry.getKey(); + } + } + configRoot.substituteEnvVars(); + boolean allLoadedConstantsPresent = + dbUrl != null && !dbUrl.isBlank() + && dbUser != null && !dbUser.isBlank() + && dbPass != null && dbPass.length > 0; + + if (!allLoadedConstantsPresent && sources!=null && sources.containsKey(defaultSourceKey)) { + DataSourceConfig src = sources.get(defaultSourceKey); + dbUrl = src.toJdbcUrl(); + dbUser = src.user; + dbPass = src.getPasswordChars(); + defaultSourceName = defaultSourceKey; + } + + if (needDb && (dbUrl == null || dbUrl.isBlank())) { + throw new IllegalStateException("Missing required db.url in both system properties and YAML config"); + } + if (needDb && (dbUser == null || dbUser.isBlank())) { + throw new IllegalStateException("Missing required db.user in both system properties and YAML config"); + } + if (needDb && (dbPass == null || dbPass.length == 0)) { + throw new IllegalStateException("Missing required db.password in both system properties and YAML config"); + } + return new ServerConfig(dbUrl, dbUser, dbPass, tools, sources, toolsMap); + } + + /** + * Builds a {@link ServerConfig} from JVM system properties(i.e., {@code -Dkey=value}). + * Validates required properties and applies sensible defaults for optional ones. + * + * @return a validated configuration + * @throws IllegalStateException if {@code db.url} is missing or blank + */ + static ServerConfig fromSystemProperties() { + Set tools = parseToolsProp(LoadedConstants.TOOLS); + boolean needDb = wantsAnyDbTools(tools); + + String dbUrl = LoadedConstants.DB_URL; + if (needDb && (dbUrl == null || dbUrl.isBlank())) { + throw new IllegalStateException("Missing required system property: db.url"); + } + + return new ServerConfig( + dbUrl, + LoadedConstants.DB_USER, + LoadedConstants.DB_PASSWORD, + tools, + new HashMap<>(), + new HashMap<>() + ); + } + + /** + * Reads a comma-separated list of tool names and returns which tools + * should be enabled. + * If the input is empty, missing, "*" or "all", it means + * “enable every tool” and returns {@code null}. + * + * Examples: + * "similarity_search,explain_plan" -> ["similarity_search","explain_plan"] + * "*" or "all" or "" -> null (treat as all tools enabled) + * + * @param prop comma-separated tool names + * @return a set of enabled tool names, or {@code null} to mean “all tools” + */ + private static Set parseToolsProp(String prop) { + if (prop == null || prop.isBlank()) return null; // null = allow all + Set s = new LinkedHashSet<>(); + for (String t : prop.split(",")) { + String k = t.trim().toLowerCase(Locale.ROOT); + if (!k.isEmpty()) s.add(k); + } + if (s.contains("*") || s.contains("all")) return null; // treat as allow all + return s; + } + + private static boolean wantsAnyDbTools(Set toolsFilter) { + if (toolsFilter == null) return true; // null == all tools enabled + for (String t : toolsFilter) { + if (DB_TOOLS.contains(t)) return true; + } + return false; + } + +} diff --git a/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/Utils.java b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/Utils.java new file mode 100644 index 0000000..d61c7a1 --- /dev/null +++ b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/Utils.java @@ -0,0 +1,381 @@ +/* + ** Oracle Database MCP Toolkit version 1.0.0 + ** + ** Copyright (c) 2025 Oracle and/or its affiliates. + ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ + */ + +package com.oracle.database.mcptoolkit; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.oracle.database.mcptoolkit.config.ConfigRoot; +import com.oracle.database.mcptoolkit.config.DataSourceConfig; +import com.oracle.database.mcptoolkit.config.ToolConfig; +import com.oracle.database.mcptoolkit.config.ToolParameterConfig; +import com.oracle.database.mcptoolkit.tools.ExplainAndExecutePlanTool; +import com.oracle.database.mcptoolkit.tools.LogAnalyzerTools; +import com.oracle.database.mcptoolkit.tools.SimilaritySearchTool; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpSyncServer; +import io.modelcontextprotocol.spec.McpSchema; +import org.yaml.snakeyaml.Yaml; +import oracle.ucp.jdbc.PoolDataSource; +import oracle.ucp.jdbc.PoolDataSourceFactory; + +import javax.sql.DataSource; +import java.io.IOException; +import java.io.Reader; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.security.Provider; +import java.security.Security; +import java.sql.Clob; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.stream.Stream; + +/** + * Utility class for managing Oracle database connections and + * executing SQL operations. + * + *

Provides methods for connection pooling (using Oracle UCP), + * executing queries, converting results to JSON, and safely handling + * database identifiers. + * + *

The connection pool uses minimal settings (1 connection). + */ +public class Utils { + private static final Logger LOG = Logger.getLogger(Utils.class.getName()); + + private static final Map dataSources = new ConcurrentHashMap<>(); + private static volatile DataSource defaultDataSource; + + /** + *

+ * Returns the list of all available tools for this server. + *

+ */ + static void addSyncToolSpecifications(McpSyncServer server, ServerConfig config) { + List specs = LogAnalyzerTools.getTools(); + for (McpServerFeatures.SyncToolSpecification spec : specs) { + String toolName = spec.tool().name(); // e.g. "get-stats", "get-queries" + if (isToolEnabled(config, toolName)) { + server.addTool(spec); + } + } + + // similarity_search + if (isToolEnabled(config, "similarity_search")) { + server.addTool(SimilaritySearchTool.getSymilaritySearchTool(config)); + } + + // explain_plan + if (isToolEnabled(config, "explain_plan")) { + server.addTool(ExplainAndExecutePlanTool.getExplainAndExecutePlanTool(config)); + } + + // ---------- Dynamically Added Tools ---------- + for (Map.Entry entry : config.tools.entrySet()) { + ToolConfig tc = entry.getValue(); + server.addTool( + McpServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder() + .name(tc.name) + .title(tc.name) + .description(tc.description) + .inputSchema(tc.buildInputSchemaJson()) + .build() + ) + .callHandler((exchange, callReq) -> + tryCall(() -> { + try (Connection c = openConnection(config, tc.dataSource)) { + PreparedStatement ps = c.prepareStatement(tc.statement); + int paramIdx = 1; + if (tc.parameters != null) { + for (ToolParameterConfig param : tc.parameters) { + Object argVal = callReq.arguments().get(param.name); + ps.setObject(paramIdx++, argVal); + } + } + if (tc.statement.trim().toLowerCase().startsWith("select")) { + ResultSet rs = ps.executeQuery(); + List> rows = rsToList(rs); + return McpSchema.CallToolResult.builder() + .structuredContent(Map.of("rows", rows, "rowCount", rows.size())) + .addTextContent(new ObjectMapper().writeValueAsString(rows)) + .build(); + } else { + int n = ps.executeUpdate(); + return McpSchema.CallToolResult.builder() + .structuredContent(Map.of("updateCount", n)) + .addTextContent("{\"updateCount\":" + n + "}") + .build(); + } + } + }) + ) + .build() + ); + } + } + + public static String getOrDefault(Object v, String def) { + if (v == null) return def; + String s = v.toString().trim(); + return s.isEmpty() ? def : s; + } + + /** + * Loads the server configuration from a YAML file specified by the configFile system property. + * If the file cannot be read or parsed, falls back to using only system properties. + * Also initializes tool names for dynamic tool entries. + * + * @return the loaded and initialized {@link ServerConfig} instance. + */ + static ServerConfig loadConfig() { + ServerConfig config; + String configFilePath = LoadedConstants.CONFIG_FILE; + ConfigRoot yamlConfig = null; + try { + try (Reader reader = Files.newBufferedReader(Paths.get(configFilePath))) { + Yaml yaml = new Yaml(); + yamlConfig = yaml.loadAs(reader, ConfigRoot.class); + } + } catch (NullPointerException ignored) { + LOG.info("YAML config file is not specified. Using values from system properties."); + } catch (Exception e) { + LOG.log(Level.SEVERE, e.getMessage(), e); + } + if (yamlConfig == null) { + config = ServerConfig.fromSystemProperties(); + } else { + String defaultSourceKey = yamlConfig.dataSources!=null?yamlConfig.dataSources.keySet().stream().findFirst().orElse(null):null; + config = ServerConfig.fromSystemPropertiesAndYaml(yamlConfig, defaultSourceKey); + } + return config; + } + + /** + * Acquires a JDBC connection from the active data source. + * + * @param cfg server configuration + * @param sourceName database source + * @return open JDBC connection + * @throws SQLException on acquisition failure + */ + public static Connection openConnection(ServerConfig cfg, String sourceName) throws SQLException { + return getOrCreateDataSource(cfg, sourceName).getConnection(); + } + + /** + * Lazily initializes and returns a UCP {@link PoolDataSource} for the given source, + * using values from {@link ServerConfig}. Each source gets its own minimal pool. + * + * @param cfg the server configuration + * @param sourceName the name of the source; if null, uses the default source + * @return a {@link DataSource} for the specified source + * @throws SQLException if creation or configuration fails + */ + private static DataSource getOrCreateDataSource(ServerConfig cfg, String sourceName) throws SQLException { + if (sourceName == null || sourceName.equals(ServerConfig.defaultSourceName)) { + if (defaultDataSource != null) return defaultDataSource; + synchronized (Utils.class) { + if (defaultDataSource != null) return defaultDataSource; + defaultDataSource = createDataSource(cfg.dbUrl, cfg.dbUser, cfg.dbPassword); + return defaultDataSource; + } + } else { + return dataSources.computeIfAbsent(sourceName, name -> { + try { + DataSourceConfig src = (cfg.sources != null) ? cfg.sources.get(name) : null; + if (src == null) throw new IllegalArgumentException("Unknown source: " + name); + return createDataSource(src.toJdbcUrl(), src.user, src.getPasswordChars()); + } catch (SQLException ex) { + throw new RuntimeException(ex); + } + }); + } + } + + private static DataSource createDataSource(String url, String user, char[] password) throws SQLException { + PoolDataSource pds = PoolDataSourceFactory.getPoolDataSource(); + pds.setConnectionFactoryClassName("oracle.jdbc.pool.OracleDataSource"); + pds.setURL(url); + if (user != null) pds.setUser(user); + if (password != null) pds.setPassword(new String(password)); + pds.setInitialPoolSize(1); + pds.setMinPoolSize(1); + pds.setConnectionWaitTimeout(10); + pds.setConnectionProperty("remarksReporting", "true"); + pds.setConnectionProperty("oracle.jdbc.vectorDefaultGetObjectType", "double[]"); + pds.setConnectionProperty("oracle.jdbc.jsonDefaultGetObjectType", "java.lang.String"); + pds.setConnectionProperty("oracle.net.keepAlive", "true"); + pds.setValidateConnectionOnBorrow(true); + return pds; + } + + /** + *

+ * Executes the provided {@link LogAnalyzerTools.ThrowingSupplier ThrowingSupplier} action, + * which may throw an {@link Exception}, and returns the resulting {@link McpSchema.CallToolResult}. + *
+ * If the action executes successfully, its {@link McpSchema.CallToolResult} is returned as-is. + * If any exception is thrown, this method returns a {@link McpSchema.CallToolResult} + * with the exception message added as {@link McpSchema.TextContent} and {@code isError} set to {@code true}. + *

+ * + *

+ * This utility method provides standardized error handling and result formatting for methods that may throw exceptions, + * ensuring that errors are consistently reported back to the MCP server. + *

+ * + * @param action The supplier action to execute, which may throw an {@link Exception} and returns a {@link McpSchema.CallToolResult}. + * @return The result of the supplier if successful, or an error {@link McpSchema.CallToolResult} if an exception occurs. + */ + public static McpSchema.CallToolResult tryCall(ThrowingSupplier action) { + try { + return action.get(); + } catch (Exception e) { + return McpSchema.CallToolResult.builder() + .addTextContent("Unexpected: " + e.getMessage()) + .isError(true) + .build(); + } + } + + @FunctionalInterface + public interface ThrowingSupplier { + T get() throws Exception; + } + + /** + * Loads external JARs from the directory given by + * the system property {@code ojdbc.ext.dir} and makes them available + * to the application at runtime. + * + *

Behavior:

+ *
    + *
  • If the property is missing/blank, does nothing.
  • + *
  • Recursively scans the directory for {@code .jar} files.
  • + *
  • Adds all found JARs to a temporary class loader and activates it.
  • + *
  • Logs basic problems (invalid dir, scan failures, no JARs found).
  • + *
+ * + */ + static void installExternalExtensionsFromDir() { + final String dir = LoadedConstants.OJDBC_EXT_DIR; + if (dir == null || dir.isBlank()) { + return; + } + + final Path root = Paths.get(dir); + if (!Files.isDirectory(root)) { + LOG.warning("[oracle-db-mcp-toolkit] ojdbc.ext.dir is not a directory: " + dir); + return; + } + final List jarUrls = new ArrayList<>(); + try (Stream walk = Files.walk(root)) { + walk.filter(p -> Files.isRegularFile(p) && p.toString().toLowerCase(Locale.ROOT).endsWith(".jar")) + .forEach(p -> { + try { + jarUrls.add(p.toUri().toURL()); + } catch (Exception e) { + LOG.log(Level.WARNING, "[oracle-db-mcp-toolkit] Failed to add jar: " + p, e); + } + }); + } catch (Exception e) { + LOG.log(Level.WARNING, "[oracle-db-mcp-toolkit] Failed to scan " + dir, e); + return; + } + + if (jarUrls.isEmpty()) { + LOG.warning("[oracle-db-mcp-toolkit] No jars found under " + dir); + return; + } + + final ClassLoader previousTccl = Thread.currentThread().getContextClassLoader(); + final URLClassLoader ucl = new URLClassLoader(jarUrls.toArray(new URL[0]), previousTccl); + Thread.currentThread().setContextClassLoader(ucl); + + try { + Class providerClass = Class.forName("oracle.security.pki.OraclePKIProvider", true, ucl); + Provider provider = (Provider) providerClass.getDeclaredConstructor().newInstance(); + Security.addProvider(provider); + } catch (Throwable ignored) {} + + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + try { Thread.currentThread().setContextClassLoader(previousTccl); } catch (Throwable ignored) {} + try { ucl.close(); } catch (Throwable ignored) {} + })); + } + + /** + * Converts a ResultSet into a list of maps (rows). + * + * @param rs a valid ResultSet + * @return list of rows with column:value mapping + * @throws SQLException if reading from ResultSet fails + */ + static List> rsToList(ResultSet rs) throws SQLException { + List> out = new ArrayList<>(); + ResultSetMetaData md = rs.getMetaData(); + int cols = md.getColumnCount(); + while (rs.next()) { + Map row = new LinkedHashMap<>(); + for (int i = 1; i <= cols; i++) { + String colName = md.getColumnLabel(i); + Object value = rs.getObject(i); + + if (value instanceof Clob clob) { + value = clobToString(clob); + } + + row.put(colName, value); + } + out.add(row); + } + return out; + } + + /** + * Safely converts a CLOB to String. + */ + private static String clobToString(Clob clob) throws SQLException { + if (clob == null) + return null; + StringBuilder sb = new StringBuilder(); + try (Reader reader = clob.getCharacterStream()) { + char[] buf = new char[4096]; + int len; + while ((len = reader.read(buf)) != -1) { + sb.append(buf, 0, len); + } + } catch (IOException e) { + throw new SQLException("Failed to read CLOB", e); + } + return sb.toString(); + } + + private static boolean isToolEnabled(ServerConfig config, String toolName) { + if (config.toolsFilter == null) { + return true; + } + String key = toolName.toLowerCase(Locale.ROOT); + return config.toolsFilter.contains(key); + } + +} \ No newline at end of file diff --git a/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/config/ConfigRoot.java b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/config/ConfigRoot.java new file mode 100644 index 0000000..38fcca6 --- /dev/null +++ b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/config/ConfigRoot.java @@ -0,0 +1,37 @@ +/* + ** Oracle Database MCP Toolkit version 1.0.0 + ** + ** Copyright (c) 2025 Oracle and/or its affiliates. + ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ + */ + +package com.oracle.database.mcptoolkit.config; + +import java.util.Map; + +/** + * Represents the root configuration for the application, containing a map of source configurations and tool configurations. + */ +public class ConfigRoot { + public Map dataSources; + public Map tools; + + /** + * Substitutes environment variables in the source and tool configurations. + *

+ * This method iterates over the source and tool configurations, substituting environment variables + * in each configuration's fields. + */ + public void substituteEnvVars() { + if (dataSources != null) { + for (DataSourceConfig sc : dataSources.values()) { + if (sc != null) sc.substituteEnvVars(); + } + } + if (tools != null) { + for (ToolConfig tc : tools.values()) { + if (tc != null) tc.substituteEnvVars(); + } + } + } +} diff --git a/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/config/DataSourceConfig.java b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/config/DataSourceConfig.java new file mode 100644 index 0000000..89dc07b --- /dev/null +++ b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/config/DataSourceConfig.java @@ -0,0 +1,87 @@ +/* + ** Oracle Database MCP Toolkit version 1.0.0 + ** + ** Copyright (c) 2025 Oracle and/or its affiliates. + ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ + */ + +package com.oracle.database.mcptoolkit.config; + +import com.oracle.database.mcptoolkit.EnvSubstitutor; + +/** + * Represents the configuration for a data source, specifically for an Oracle database. + *

+ * This class encapsulates the necessary properties to establish a connection to the database. + */ +public class DataSourceConfig { + /** + * The hostname or IP address of the database server. + */ + public String host; + + /** + * The port number on which the database server is listening. + */ + public String port; + + /** + * The Oracle service name of the database. + */ + public String database; + + /** + * The JDBC URL for the database connection. If not provided, it will be constructed + * using the host, port, and database properties. + */ + public String url; + + /** + * The username to use for the database connection. + */ + public String user; + + /** + * The password to use for the database connection. + */ + public String password; + + private transient char[] passwordChars; + + /** + * Returns the JDBC URL for the database connection. If the {@link #url} property is not set, + * it will be constructed using the {@link #host}, {@link #port}, and {@link #database} properties. + * + * @return the JDBC URL for the database connection + */ + public String toJdbcUrl() { + if(url == null) { + return String.format("jdbc:oracle:thin:@%s:%s/%s", host, port, database); + } else { + return url; + } + } + + /** + * Substitutes environment variables in the configuration properties. + *

+ * This method replaces placeholders in the form of ${VARIABLE_NAME} with the actual environment variable values. + */ + public void substituteEnvVars() { + this.host = EnvSubstitutor.substituteEnvVars(this.host); + this.port = EnvSubstitutor.substituteEnvVars(this.port); + this.database = EnvSubstitutor.substituteEnvVars(this.database); + this.url = EnvSubstitutor.substituteEnvVars(this.url); + this.user = EnvSubstitutor.substituteEnvVars(this.user); + this.passwordChars = EnvSubstitutor.substituteEnvVarsInCharArray(this.getPasswordChars()); + } + + public char[] getPasswordChars() { + if (passwordChars == null && password != null) { + passwordChars = password.toCharArray(); + // To reduce exposure + password = null; + } + return passwordChars; + } +} \ No newline at end of file diff --git a/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/config/ToolConfig.java b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/config/ToolConfig.java new file mode 100644 index 0000000..2e949e9 --- /dev/null +++ b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/config/ToolConfig.java @@ -0,0 +1,86 @@ +/* + ** Oracle Database MCP Toolkit version 1.0.0 + ** + ** Copyright (c) 2025 Oracle and/or its affiliates. + ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ + */ + +package com.oracle.database.mcptoolkit.config; + +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import com.fasterxml.jackson.databind.node.ObjectNode; +import com.oracle.database.mcptoolkit.EnvSubstitutor; + +import java.util.List; + +/** + * Represents a tool configuration, encapsulating its properties and behavior. + */ +public class ToolConfig { + /** + * The tool name, derived from the YAML key. + */ + public String name; + + /** + * Reference key from data sources. + */ + public String dataSource; + + /** + * A brief description of the tool. + */ + public String description; + + /** + * A list of parameter configurations for the tool. + */ + public List parameters; + + /** + * The SQL statement to be executed by the tool. + */ + public String statement; + + /** + * Substitutes environment variables in the tool configuration. + *

+ * Replaces placeholders in the tool's name, source, description, and statement with their corresponding environment variable values. + * Also substitutes environment variables in the tool's parameters, if any. + */ + public void substituteEnvVars() { + this.name = EnvSubstitutor.substituteEnvVars(this.name); + this.dataSource = EnvSubstitutor.substituteEnvVars(this.dataSource); + this.description = EnvSubstitutor.substituteEnvVars(this.description); + this.statement = EnvSubstitutor.substituteEnvVars(this.statement); + if (this.parameters != null) { + for (ToolParameterConfig param : this.parameters) { + if (param != null) param.substituteEnvVars(); + } + } + } + + public String buildInputSchemaJson() { + ObjectNode schema = JsonNodeFactory.instance.objectNode(); + schema.put("type", "object"); + ObjectNode properties = schema.putObject("properties"); + ArrayNode required = JsonNodeFactory.instance.arrayNode(); + + for (ToolParameterConfig param : this.parameters) { + if (param == null) { + continue; + } + ObjectNode prop = properties.putObject(param.name); + prop.put("type", param.type); + prop.put("description", param.description); + if (param.required) { + required.add(param.name); + } + } + if (!required.isEmpty()) { + schema.set("required", required); + } + return schema.toString(); + } +} \ No newline at end of file diff --git a/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/config/ToolParameterConfig.java b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/config/ToolParameterConfig.java new file mode 100644 index 0000000..226a3da --- /dev/null +++ b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/config/ToolParameterConfig.java @@ -0,0 +1,49 @@ +/* + ** Oracle Database MCP Toolkit version 1.0.0 + ** + ** Copyright (c) 2025 Oracle and/or its affiliates. + ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ + */ + +package com.oracle.database.mcptoolkit.config; + +import com.oracle.database.mcptoolkit.EnvSubstitutor; + +/** + * Represents a configuration for a tool parameter. + *

+ * This class encapsulates the properties of a tool parameter, including its name, type, description, and whether it is required. + */ +public class ToolParameterConfig { + /** + * The name of the tool parameter. + */ + public String name; + + /** + * The data type of the tool parameter. + */ + public String type; + + /** + * A human-readable description of the tool parameter. + */ + public String description; + + /** + * Indicates whether the tool parameter is required. + */ + public boolean required; + + /** + * Substitutes environment variables in the tool parameter's properties. + *

+ * This method replaces any environment variable references in the {@link #name}, {@link #type}, and {@link #description} fields with their corresponding values. + */ + public void substituteEnvVars() { + this.name = EnvSubstitutor.substituteEnvVars(this.name); + this.type = EnvSubstitutor.substituteEnvVars(this.type); + this.description = EnvSubstitutor.substituteEnvVars(this.description); + } + +} \ No newline at end of file diff --git a/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/oauth/OAuth2Configuration.java b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/oauth/OAuth2Configuration.java new file mode 100644 index 0000000..5aa82ab --- /dev/null +++ b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/oauth/OAuth2Configuration.java @@ -0,0 +1,157 @@ +/* + ** Oracle Database MCP Toolkit version 1.0.0 + ** + ** Copyright (c) 2025 Oracle and/or its affiliates. + ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ + */ + +package com.oracle.database.mcptoolkit.oauth; + +import com.oracle.database.mcptoolkit.LoadedConstants; + +import java.util.ArrayList; +import java.util.List; +import java.util.logging.Logger; + +/** + * The OAuth2Configuration class is a singleton that manages OAuth2 authentication configuration settings. + * It reads configuration values from system properties and provides access to them via getter methods. + *

+ * This class also handles logging based on whether authentication and OAuth2 are enabled or configured. + * If OAuth2 is not properly configured, it initializes a TokenGenerator for local token generation. + */ +public class OAuth2Configuration { + private static final Logger LOG = Logger.getLogger(OAuth2Configuration.class.getName()); + private static final OAuth2Configuration INSTANCE = new OAuth2Configuration(); + + /** The OAuth2 authorization server URL. */ + private final String authServer; + /** The OAuth2 token introspection endpoint URL. */ + private final String introspectionEndpoint; + /** The OAuth2 client ID. */ + private final String clientId; + /** The OAuth2 client secret. */ + private final String clientSecret; + + /** Flag indicating whether authentication is enabled. */ + private final boolean isAuthenticationEnabled; + /** Flag indicating whether OAuth2 is fully configured. */ + private final boolean isOAuth2Configured; + + /** + * Private constructor to initialize the singleton instance. + * Reads system properties for authentication settings and OAuth2 configuration. + * Logs warnings or info messages based on the configuration status. + * If OAuth2 is not configured, initializes a TokenGenerator for local token generation. + */ + private OAuth2Configuration() { + isAuthenticationEnabled = LoadedConstants.ENABLE_AUTH; + authServer = LoadedConstants.AUTH_SERVER; + introspectionEndpoint = LoadedConstants.INTROSPECTION_ENDPOINT; + clientId = LoadedConstants.CLIENT_ID; + clientSecret = LoadedConstants.CLIENT_SECRET; + isOAuth2Configured = authServer != null && introspectionEndpoint != null && clientId != null && clientSecret != null; + + if (!isAuthenticationEnabled) + LOG.warning("Authentication is disabled"); + else { + LOG.info("Authentication is enabled"); + + if (isOAuth2Configured) + LOG.info("OAuth2 is configured"); + else { + LOG.warning("OAuth2 is not configured"); + if (authServer != null || introspectionEndpoint != null || clientId != null || clientSecret != null) { + final var warningMessage = getMissingConfigurationWarningMessage(); + + LOG.warning(warningMessage); + } + // Generate a local UUID string token or load it from ORACLE_DB_TOOLBOX_AUTH_TOKEN env var. + TokenGenerator.getInstance(); + } + } + } + + private String getMissingConfigurationWarningMessage() { + final List warningMessages = new ArrayList<>(); + final var mainMessage = "The following OAuth system properties are not configured correctly: "; + + if (authServer == null) + warningMessages.add("Authentication server URL (-DauthServer)"); + + if (introspectionEndpoint == null) + warningMessages.add("Introspection endpoint (-DintrospectionEndpoint)"); + + if (clientId == null) + warningMessages.add("Client ID (-DclientId)"); + + if (clientSecret == null) + warningMessages.add("Client secret (-DclientSecret)"); + + return mainMessage + String.join(", ", warningMessages); + } + + /** + * Returns the singleton instance of OAuth2Configuration. + * + * @return the singleton instance + */ + public static OAuth2Configuration getInstance() { + return INSTANCE; + } + + /** + * Returns the OAuth2 authorization server URL. + * + * @return the auth server URL + */ + public String getAuthServer() { + return authServer; + } + + /** + * Returns the OAuth2 client ID. + * + * @return the client ID + */ + public String getClientId() { + return clientId; + } + + /** + * Returns the OAuth2 client secret. + * + * @return the client secret + */ + public String getClientSecret() { + return clientSecret; + } + + /** + * Returns the OAuth2 token introspection endpoint URL. + * + * @return the introspection endpoint URL + */ + public String getIntrospectionEndpoint() { + return introspectionEndpoint; + } + + /** + * Checks if authentication is enabled. + * + * @return true if authentication is enabled, false otherwise + */ + public boolean isAuthenticationEnabled() { + return isAuthenticationEnabled; + } + + /** + * Checks if OAuth2 is fully configured. + * + * @return true if OAuth2 is configured, false otherwise + */ + public boolean isOAuth2Configured() { + return isOAuth2Configured; + } +} + diff --git a/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/oauth/OAuth2TokenValidator.java b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/oauth/OAuth2TokenValidator.java new file mode 100644 index 0000000..f6eac06 --- /dev/null +++ b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/oauth/OAuth2TokenValidator.java @@ -0,0 +1,94 @@ +/* + ** Oracle Database MCP Toolkit version 1.0.0 + ** + ** Copyright (c) 2025 Oracle and/or its affiliates. + ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ + */ + +package com.oracle.database.mcptoolkit.oauth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletResponse; + +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.net.http.HttpResponse.BodyHandlers; +import java.util.Base64; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * The OAuth2TokenValidator class is responsible for validating OAuth2 access tokens. + * It checks if the provided access token is valid by either using a local TokenGenerator + * if OAuth2 is not configured, or by performing token introspection against an OAuth2 + * authorization server if OAuth2 is properly configured. + *

+ * This class relies on the OAuth2Configuration singleton to retrieve necessary settings + * such as the introspection endpoint, client credentials, and configuration flags. + *

+ */ +public class OAuth2TokenValidator { + private static final OAuth2Configuration OAUTH_CONFIG = OAuth2Configuration.getInstance(); + private static final Logger LOG = Logger.getLogger(OAuth2TokenValidator.class.getName()); + + /** + * Validates the given access token. + *

+ * If OAuth2 is not configured (as determined by OAuth2Configuration), this method + * delegates validation to the TokenGenerator instance for local verification. + * Otherwise, it performs an HTTP POST request to the OAuth2 introspection endpoint + * using the configured client credentials. The response is parsed as JSON, and the + * "active" field is checked to determine token validity. + *

+ * + * @param accessToken the OAuth2 access token to validate; must not be null or blank + * @return true if the token is valid, false otherwise + * @throws RuntimeException if an error occurs during token validation (e.g., network issues), + * though exceptions are logged and handled internally by returning false + */ + public boolean isTokenValid(final String accessToken) { + if (!OAUTH_CONFIG.isOAuth2Configured()) + return TokenGenerator.getInstance().verifyToken(accessToken); + + boolean isTokenValid = false; + if (accessToken == null || accessToken.isBlank()) + return false; + + final var clientCredentials = "%s:%s".formatted(OAUTH_CONFIG.getClientId(), OAUTH_CONFIG.getClientSecret()); + final var encodedClientCredentials = Base64.getEncoder() + .encodeToString(clientCredentials.getBytes()); + final var requestBody = "token=" + accessToken; + + try { + final HttpClient client = HttpClient.newHttpClient(); + final HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(OAUTH_CONFIG.getIntrospectionEndpoint())) + .header("Authorization", "Basic " + encodedClientCredentials) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(requestBody)) + .build(); + + final HttpResponse response = client.send(request, BodyHandlers.ofString()); + + final int statusCode = response.statusCode(); + if (statusCode == HttpServletResponse.SC_OK) { + final var mapper = new ObjectMapper(); + final var jsonNode = mapper.readTree(response.body()); + + isTokenValid = jsonNode.get("active").asBoolean(); + } + } catch (IOException | InterruptedException e) { + LOG.log(Level.SEVERE, e.getMessage(), e); + + if (e instanceof InterruptedException) + Thread.currentThread() + .interrupt(); + } + + return isTokenValid; + } + +} diff --git a/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/oauth/TokenGenerator.java b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/oauth/TokenGenerator.java new file mode 100644 index 0000000..a7a82d8 --- /dev/null +++ b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/oauth/TokenGenerator.java @@ -0,0 +1,59 @@ +/* + ** Oracle Database MCP Toolkit version 1.0.0 + ** + ** Copyright (c) 2025 Oracle and/or its affiliates. + ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ + */ + +package com.oracle.database.mcptoolkit.oauth; + +import static com.oracle.database.mcptoolkit.LoadedConstants.ORACLE_DB_TOOLKIT_AUTH_TOKEN; + +import java.util.UUID; +import java.util.logging.Level; +import java.util.logging.Logger; + +/** + * The TokenGenerator class is responsible for generating and verifying authorization tokens. + * It follows the singleton pattern to ensure a single instance throughout the application. + *

+ * The generated token can be overridden using the {@code ORACLE_DB_TOOLBOX_AUTH_TOKEN} environment variable. + * If not overridden, a random UUID is used as the token. + */ +public class TokenGenerator { + private static final Logger LOG = Logger.getLogger(TokenGenerator.class.getName()); + private static final TokenGenerator INSTANCE = new TokenGenerator(); + + private final String generatedToken; + + /** + * Private constructor to prevent instantiation from outside the class. + * Initializes the generated token based on the {@code ORACLE_DB_TOOLBOX_AUTH_TOKEN} environment variable or a random UUID. + */ + private TokenGenerator() { + generatedToken = ORACLE_DB_TOOLKIT_AUTH_TOKEN != null ? ORACLE_DB_TOOLKIT_AUTH_TOKEN : UUID.randomUUID().toString() ; + LOG.log(Level.INFO, "Authorization token generated (for testing and development use only): {0}", generatedToken); + } + + /** + * Returns the singleton instance of the TokenGenerator. + * This method ensures that only one instance of the class exists throughout the application. + * + * @return the singleton TokenGenerator instance + */ + public static TokenGenerator getInstance() { + return INSTANCE; + } + + /** + * Verifies if the provided token matches the internally generated token. + * This method performs a case-sensitive string comparison. + * + * @param token the token to verify against the generated token + * @return true if the provided token equals the generated token, false otherwise + */ + public boolean verifyToken(final String token) { + return generatedToken.equals(token); + } + +} diff --git a/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/ExplainAndExecutePlanTool.java b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/ExplainAndExecutePlanTool.java new file mode 100644 index 0000000..42ad06f --- /dev/null +++ b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/ExplainAndExecutePlanTool.java @@ -0,0 +1,296 @@ +/* + ** Oracle Database MCP Toolkit version 1.0.0 + ** + ** Copyright (c) 2025 Oracle and/or its affiliates. + ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ + */ + +package com.oracle.database.mcptoolkit.tools; + +import com.oracle.database.mcptoolkit.ServerConfig; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.spec.McpSchema; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.LinkedHashMap; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static com.oracle.database.mcptoolkit.Utils.openConnection; +import static com.oracle.database.mcptoolkit.Utils.tryCall; + +/** + * Provides functionality for explaining and executing Oracle SQL plans. + * This class contains methods to generate execution plans for SQL queries and + * to explain these plans in a human-readable format. + */ +public class ExplainAndExecutePlanTool { + /** + * Returns a tool specification for the "explain_plan" tool. + * This tool generates an Oracle execution plan for the provided SQL and + * produces an accompanying LLM prompt to explain and tune the plan. + * + * @param config Server configuration + * @return Tool specification for the "explain_plan" tool + */ + public static McpServerFeatures.SyncToolSpecification getExplainAndExecutePlanTool(ServerConfig config) { + return + McpServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder() + .name("explain_plan") + .title("Explain Plan (static or dynamic)") + .description(""" + Returns an Oracle execution plan for the provided SQL. + mode: "static" (EXPLAIN PLAN) or "dynamic" (DISPLAY_CURSOR of the last execution in this session). + Response includes: planText (DBMS_XPLAN output) and llmPrompt (ready-to-use for the LLM). + + You are an Oracle SQL performance expert. Explain the execution plan to the user in clear language and then provide prioritized, practical tuning advice. + + Instructions: + 1) Summarize how the query executes (major steps, joins, access paths). + 2) Point out potential bottlenecks (scans, sorts, joins, TEMP/PGA, cardinality mismatches if present). + 3) Give the top 3–5 tuning ideas with rationale (indexes, predicates, rewrites, stats/histograms, hints if appropriate). + 4) Mention any trade-offs or risks. + + Note to model: + If the sql is a dml operation and it was actually executed No permanent data changes were committed. When explaining the plan, mention this statement will be rolled back + """) + .inputSchema(ToolSchemas.EXPLAIN_PLAN) + .build()) + .callHandler((exchange, callReq) -> tryCall(() -> { + try (Connection c = openConnection(config, null)) { + final String sql = String.valueOf(callReq.arguments().get("sql")); + if (sql == null || sql.isBlank()) { + return new McpSchema.CallToolResult("Parameter 'sql' is required", true); + } + final String mode = String.valueOf(callReq.arguments().getOrDefault("mode", "static")) + .toLowerCase(Locale.ROOT); + + Boolean executeArg = null; + Object exObj = callReq.arguments().get("execute"); + if (exObj != null) executeArg = Boolean.parseBoolean(String.valueOf(exObj)); + + Integer maxRows = null; + try { + Object mr = callReq.arguments().get("maxRows"); + if (mr != null) maxRows = Integer.parseInt(String.valueOf(mr)); + } catch (Exception ignored) {} + + final String xplanOptions = Optional.ofNullable(callReq.arguments().get("xplanOptions")) + .map(Object::toString).orElse(null); + + var res = getExplainPlan( + c, + sql, + "dynamic".equals(mode), + maxRows, + executeArg, + xplanOptions + ); + Map payload = new LinkedHashMap<>(); + payload.put("mode", mode); + payload.put("sql", sql); + payload.put("planText", res.planText()); + + return McpSchema.CallToolResult.builder() + .structuredContent(payload) + .addTextContent(res.planText()) + .build(); + } + })) + .build(); + } + + + /** + * Returns an execution plan (static or dynamic) for the given SQL and also produces + * an accompanying LLM prompt to explain and tune the plan. + * - static → EXPLAIN PLAN (no execution, estimated plan only) + * - dynamic → DISPLAY_CURSOR (requires a real cursor; may lightly execute the SQL) + * + * @param c JDBC connection + * @param sql SQL to analyze + * @param dynamic true = dynamic plan, false = static plan + * @param maxRows limit when lightly executing SELECT (default = 1) + * @param execute whether to execute or just parse (null = auto per SQL type) + * @param xplanOptions DBMS_XPLAN formatting options + */ + static ExplainResult getExplainPlan( + Connection c, + String sql, + boolean dynamic, + Integer maxRows, + Boolean execute, + String xplanOptions + ) throws Exception { + + if (!dynamic) { + try (Statement st = c.createStatement()) { + st.executeUpdate("EXPLAIN PLAN FOR " + sql); + } + String planText = readXplan(c, false, xplanOptions); + return new ExplainResult(planText); + } + + // dynamic mode → prepare or execute depending on flags + runQueryLightweight(c, sql, maxRows, execute); + + String planText = readXplan(c, true, xplanOptions); + return new ExplainResult(planText); + } + + + /** + * Prepare or execute a statement lightly so a cursor exists for DISPLAY_CURSOR. + * Handles SELECT, DML, and DDL safely. + * + * @param c open connection + * @param sql SQL text + * @param maxRows optional limit (applies to SELECT only) + * @param execute whether to actually execute (null = smart default) + */ + private static void runQueryLightweight(Connection c, String sql, Integer maxRows, Boolean execute) + throws SQLException { + + boolean isSelect = looksSelect(sql); + boolean doExecute = (execute != null) ? execute : isSelect; // smart default + + if (!doExecute) { + // just parse (safe) + try (PreparedStatement ps = c.prepareStatement(sql)) { /* parse only */ } + return; + } + + if (isSelect) { + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setMaxRows((maxRows != null && maxRows > 0) ? maxRows : 1); + ps.setFetchSize(1); + try (ResultSet rs = ps.executeQuery()) { + if (rs.next()) { /* first row only */ } + } + } + return; + } + + // DML/DDL: execute inside rollback-safe transaction (to minimise side effects) + boolean prevAutoCommit = c.getAutoCommit(); + try { + c.setAutoCommit(false); + + String execSql = injectGatherStatsHintAfterVerb(sql); + try (PreparedStatement ps = c.prepareStatement(execSql)) { + ps.execute(); + } + + c.rollback(); + } finally { + c.setAutoCommit(prevAutoCommit); + } + + } + + /** Read DBMS_XPLAN for either EXPLAIN PLAN or last cursor. */ + private static String readXplan(Connection c, boolean dynamic, String xplanOptions) throws SQLException { + final String opts = (xplanOptions == null || xplanOptions.isBlank()) + ? (dynamic ? "ALLSTATS LAST +PEEKED_BINDS +OUTLINE +PROJECTION" + : "BASIC +OUTLINE +PROJECTION +ALIAS") + : xplanOptions; + final String q = dynamic + ? ("SELECT PLAN_TABLE_OUTPUT FROM TABLE(DBMS_XPLAN.DISPLAY_CURSOR(NULL, NULL, '" + opts + "'))") + : ("SELECT PLAN_TABLE_OUTPUT FROM TABLE(DBMS_XPLAN.DISPLAY(NULL, NULL, '" + opts + "'))"); + + StringBuilder sb = new StringBuilder(); + try (Statement st = c.createStatement(); ResultSet rs = st.executeQuery(q)) { + while (rs.next()) sb.append(Objects.toString(rs.getString(1), "")).append('\n'); + } + return sb.toString().trim(); + } + + /** + * Checks if the provided SQL looks like a SELECT. + * + * @param sql the SQL string + * @return true if it begins with "SELECT" (case-insensitive) + */ + static boolean looksSelect(String sql) { + return sql != null && sql.trim().regionMatches(true, 0, "SELECT", 0, 6); + } + + private static final Pattern DML_VERB = + Pattern.compile("^\\s*(?:--.*?$|/\\*.*?\\*/\\s*)*(UPDATE|DELETE|INSERT|MERGE)\\b", + Pattern.CASE_INSENSITIVE | Pattern.DOTALL | Pattern.MULTILINE); + + /** Injects "/*+ gather_plan_statistics +*\/" immediately after the first DML verb. + * - Preserves leading whitespace and comments + * - No-op if the SQL already contains the hint (case-insensitive) + * - Skips if not a DML statement (e.g., SELECT/BEGIN/DECLARE/ALTER/CREATE) + */ + static String injectGatherStatsHintAfterVerb(String sql) { + if (sql == null) return null; + String s = sql.trim(); + if (s.toLowerCase(Locale.ROOT).contains("gather_plan_statistics")) return sql; + String head = s.length() >= 16 ? s.substring(0, 16).toLowerCase(Locale.ROOT) : s.toLowerCase(Locale.ROOT); + if (head.startsWith("begin") || head.startsWith("declare") || isDdl(head)) { + return sql; + } + return injectAfterMatch(sql, DML_VERB, "/*+ gather_plan_statistics */", "gather_plan_statistics"); + } + + static final Pattern FIRST_WORD = Pattern.compile("^\\s*([A-Za-z0-9_]+)"); + + /** + * DDL detector (CREATE/ALTER/DROP/TRUNCATE/RENAME/COMMENT/GRANT/REVOKE). + * Used to block DDL inside user-managed transactions. + */ + static boolean isDdl(String sql) { + if (sql == null) return false; + String s = sql.trim().toUpperCase(); + return s.startsWith("CREATE ") + || s.startsWith("ALTER ") + || s.startsWith("DROP ") + || s.startsWith("TRUNCATE ") + || s.startsWith("RENAME ") + || s.startsWith("COMMENT ") + || s.startsWith("GRANT ") + || s.startsWith("REVOKE "); + } + + record ExplainResult(String planText) {} + + /** + * Injects a given string after the first match group found by the pattern, + * unless the SQL already contains the skip string (case-insensitive). + * + * @param sql SQL statement to operate on + * @param pattern Regex pattern with a capturing group for insertion point + * @param injection Text to inject + * @param skipIfContains Injection is skipped if this substring (case-insensitive) is present + * @return Modified SQL with the injection, or original if no changes made + */ + private static String injectAfterMatch( + String sql, Pattern pattern, String injection, String skipIfContains + ) { + if (sql == null) return null; + String s = sql.trim(); + if (s.toLowerCase(Locale.ROOT).contains(skipIfContains.toLowerCase(Locale.ROOT))) + return sql; + Matcher m = pattern.matcher(sql); + if (!m.find()) return sql; + int start = m.start(1), end = m.end(1); + String word = sql.substring(start, end); + StringBuilder out = new StringBuilder(sql.length() + injection.length() + 4); + out.append(sql, 0, start) + .append(word) + .append(" ").append(injection) + .append(sql.substring(end)); + return out.toString(); + } +} diff --git a/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/LogAnalyzerTools.java b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/LogAnalyzerTools.java new file mode 100644 index 0000000..b6373e5 --- /dev/null +++ b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/LogAnalyzerTools.java @@ -0,0 +1,342 @@ +/* + ** Oracle Database MCP Toolkit version 1.0.0 + ** + ** Copyright (c) 2025 Oracle and/or its affiliates. + ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ + */ + +package com.oracle.database.mcptoolkit.tools; + +import com.oracle.database.mcptoolkit.Utils; +import com.oracle.database.jdbc.logs.model.JDBCConnectionEvent; +import com.oracle.database.jdbc.logs.model.JDBCExecutedQuery; +import com.oracle.database.jdbc.logs.model.LogError; +import com.oracle.database.jdbc.logs.model.RDBMSError; +import com.oracle.database.jdbc.logs.model.RDBMSPacketDump; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.Tool; + +import com.oracle.database.jdbc.logs.analyzer.JDBCLog; +import com.oracle.database.jdbc.logs.analyzer.RDBMSLog; + +import java.io.File; +import java.io.IOException; +import java.util.Arrays; +import java.util.List; +import java.util.stream.Collectors; + +/** + *

+ * This class provides an MCP Server for Oracle JDBC Log Analyzer with tools + * to analyze and process Oracle JDBC and RDBMS/SQLNet log files. + *

+ */ +public final class LogAnalyzerTools { + + private static final String FILE_PATH = "filePath"; + private static final String SECOND_FILE_PATH = "secondFilePath"; + private static final String CONNECTION_ID = "connectionId"; + + /** + *

+ * Returns a list of available tools for Oracle JDBC Log Analyzer. + * The tools provided include: + *

    + *
  • {@code get-jdbc-stats}: Retrieves high-level statistics from an Oracle JDBC thin log file.
  • + *
  • {@code get-jdbc-queries}: Extracts all executed SQL queries from an Oracle JDBC thin log file.
  • + *
  • {@code get-jdbc-errors}: Processes a specified Oracle JDBC thin log file and extracts all reported errors.
  • + *
  • {@code list-log-files-from-directory}: Lists all Oracle JDBC log files present in the specified directory path.
  • + *
  • {@code jdbc-log-comparison}: Compares two Oracle JDBC log files and provides a JSON report highlighting differences and similarities.
  • + *
  • {@code get-jdbc-connection-events}: Retrieves opened and closed JDBC connection events from the log file.
  • + *
  • {@code get-rdbms-errors}: Processes a specified Oracle RDBMS/SQLNet trace file to extract all reported errors.
  • + *
  • {@code get-rdbms-packet-dumps}: Extracts packet dump information from a specified RDBMS/SQLNet trace file that matches a given connection ID.
  • + *
+ *

+ * + * @return a list of {@link McpServerFeatures.SyncToolSpecification SyncToolSpecification} instances representing the available tools. + */ + public static List getTools() { + return List.of( + getStatsTool(), + getQueriesTool(), + getErrorsTool(), + getListLogsDirectoryTool(), + getConnectionEventsTool(), + logComparisonTool(), + getRdbmsErrorsTool(), + getPacketDumpsTool()); + } + + /** + *

+ * Builds and returns a {@link SyncToolSpecification SyncToolSpecification} + * for the {@code get-jdbc-stats} tool, which retrieves high-level statistics from an Oracle JDBC thin log file. + * The tool gathers information such as error count, the number of sent and + * received packets, and byte counts from the specified log file. + *

+ * + * @return a {@link SyncToolSpecification SyncToolSpecification} instance for the {@code get-jdbc-stats} tool. + */ + private static SyncToolSpecification getStatsTool() { + return SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder() + .name("get-jdbc-stats") + .title("Get JDBC Stats") + .description("Return aggregated stats (error count, packets, bytes) from an Oracle JDBC thin log.") + .inputSchema(ToolSchemas.FILE_PATH_SCHEMA) + .build()) + .callHandler((exchange, callReq) -> Utils.tryCall( () -> { + final var filePath = String.valueOf(callReq.arguments().get(FILE_PATH)); + final var stats = new JDBCLog(filePath).getStats(); + return McpSchema.CallToolResult.builder() + .addTextContent(stats.toJSONString()) + .isError(false) + .build(); + })) + .build(); + } + + /** + *

+ * Builds and returns a {@link SyncToolSpecification SyncToolSpecification} for the {@code get-jdbc-queries} tool. + * This tool extracts all executed SQL queries from an Oracle JDBC thin log file. + * For each query, it provides the corresponding timestamp, execution time, connection id and tenant. + *

+ * + * @return a {@link SyncToolSpecification SyncToolSpecification} instance for the {@code get-jdbc-queries} tool. + */ + private static SyncToolSpecification getQueriesTool() { + return SyncToolSpecification.builder() + .tool(Tool.builder() + .name("get-jdbc-queries") + .title("Get JDBC Queries") + .description("Get all executed queries from an Oracle JDBC thin log file, including the timestamp and execution time.") + .inputSchema(ToolSchemas.FILE_PATH_SCHEMA) + .build()) + .callHandler((exchange, callReq) -> Utils.tryCall(() -> { + final var filePath = String.valueOf(callReq.arguments().get(FILE_PATH)); + final var queries = new JDBCLog(filePath).getQueries(); + String results = queries.stream() + .map(JDBCExecutedQuery::toJSONString) + .collect(Collectors.joining(",", "[", "]")); + return McpSchema.CallToolResult.builder() + .addTextContent(results) + .isError(false) + .build(); + })) + .build(); + } + + + /** + *

+ * Builds and returns a {@link SyncToolSpecification SyncToolSpecification} for the get-jdbc-errors tool. + * This tool processes a specified Oracle JDBC thin log file and extracts all reported errors. + * Each error record includes details such as the stack trace and additional log context. + *

+ * ` + * @return a {@link SyncToolSpecification SyncToolSpecification} representing the get-jdbc-errors tool. + */ + private static SyncToolSpecification getErrorsTool() { + return SyncToolSpecification.builder() + .tool(Tool.builder() + .name("get-jdbc-errors") + .title("Get JDBC Errors") + .description("Get all reported errors from an Oracle JDBC thin log file, including stacktrace and log context.") + .inputSchema(ToolSchemas.FILE_PATH_SCHEMA) + .build()) + .callHandler((exchange, callReq) -> Utils.tryCall(() -> { + final var filePath = String.valueOf(callReq.arguments().get(FILE_PATH)); + final var errors = new JDBCLog(filePath).getLogErrors(); + String results = errors.stream() + .map(LogError::toJSONString) + .collect(Collectors.joining(",", "[", "]")); + return McpSchema.CallToolResult.builder() + .addTextContent(results) + .isError(false) + .build(); + })) + .build(); + } + + + /** + *

+ * Builds and returns a {@link SyncToolSpecification SyncToolSpecification} for the {@code list-log-files-from-directory} tool. + * This tool lists all Oracle JDBC log files present in the specified directory path. + *

+ * + * @return a {@link SyncToolSpecification SyncToolSpecification} for the {@code list-log-files-from-directory} tool. + */ + private static SyncToolSpecification getListLogsDirectoryTool() { + return SyncToolSpecification.builder() + .tool(Tool.builder() + .name("list-log-files-from-directory") + .title("List Log Files From Directory") + .description("List all visible files from a specified directory, which helps the user analyze multiple files with one prompt.") + .inputSchema(ToolSchemas.FILE_PATH_SCHEMA) + .build()) + .callHandler((exchange, callReq) -> Utils.tryCall(() -> { + final var directoryPath = String.valueOf(callReq.arguments().get(FILE_PATH)); + final var directory = new File(directoryPath); + final var files = directory.listFiles(); + if (files == null || files.length == 0) { + throw new IOException("No files found in the specified directory."); + } + String results =Arrays.stream(files) + .filter(file -> !file.isHidden() && file.isFile()) + .map(File::getName) + .collect(Collectors.joining(",", "[", "]")); + + return McpSchema.CallToolResult.builder() + .addTextContent(results) + .isError(false) + .build(); + })) + .build(); + } + + /** + *

+ * Builds and returns a {@link SyncToolSpecification SyncToolSpecification} for the {@code jdbc-log-comparison} tool. + * This tool enables comparison of two Oracle JDBC log files. It analyzes the specified log files and provides a JSON report + * highlighting differences and similarities in performance metrics, encountered errors, and network-related information. + *

+ * + * @return a {@link SyncToolSpecification SyncToolSpecification} instance that defines the {@code jdbc-log-comparison} tool. + */ + private static SyncToolSpecification logComparisonTool() { + return SyncToolSpecification.builder() + .tool(Tool.builder() + .name("jdbc-log-comparison") + .title("JDBC Log Comparison") + .description("Compare two JDBC log files for performance metrics, errors, and network information.") + .inputSchema(ToolSchemas.FILE_COMPARISON_SCHEMA) + .build()) + .callHandler((exchange, callReq) -> Utils.tryCall(() -> { + final var filePath = String.valueOf(callReq.arguments().get(FILE_PATH)); + final var secondFilePath = String.valueOf(callReq.arguments().get(SECOND_FILE_PATH)); + final var comparison = new JDBCLog(filePath).compareTo(secondFilePath); + return McpSchema.CallToolResult.builder() + .addTextContent(comparison.toJSONString()) + .isError(false) + .build(); + })) + .build(); + } + + /** + *

+ * Builds and returns a {@link SyncToolSpecification SyncToolSpecification} for the {@code get-jdbc-connection-events} tool. + *

+ * + * @return a {@link SyncToolSpecification SyncToolSpecification} instance for the {@code get-jdbc-connection-events} tool. + */ + private static SyncToolSpecification getConnectionEventsTool() { + return SyncToolSpecification.builder() + .tool(Tool.builder() + .name("get-jdbc-connection-events") + .title("Get JDBC Connection Events") + .description("Retrieve opened and closed JDBC connection events from the log file with timestamp and connection details.") + .inputSchema(ToolSchemas.FILE_PATH_SCHEMA) + .build()) + .callHandler((exchange, callReq) -> Utils.tryCall(() -> { + final var filePath = String.valueOf(callReq.arguments().get(FILE_PATH)); + final var events = new JDBCLog(filePath).getConnectionEvents(); + String results = events.stream() + .map(JDBCConnectionEvent::toJSONString) + .collect(Collectors.joining(",", "[", "]")); + return McpSchema.CallToolResult.builder() + .addTextContent(results) + .isError(false) + .build(); + })) + .build(); + } + + /** + *

+ * Builds and returns a {@link SyncToolSpecification SyncToolSpecification} for the get-rdbms-errors tool. + * This tool processes a specified Oracle RDBMS/SQLNet trace file to extract all reported errors. + * Each extracted error record includes relevant details, such as error messages and context information, + * and is serialized in JSON format. + *

+ * + * @return a {@link SyncToolSpecification SyncToolSpecification} representing the {@code get-rdbms-errors} tool. + */ + private static SyncToolSpecification getRdbmsErrorsTool() { + return SyncToolSpecification.builder() + .tool(Tool.builder() + .name("get-rdbms-errors") + .title("Get RDBMS/SQLNet Errors") + .description("Retrieve errors from an RDBMS/SQLNet trace file.") + .inputSchema(ToolSchemas.RDBMS_TOOLS_SCHEMA) + .build()) + .callHandler((exchange, callReq) -> Utils.tryCall(() -> { + final var logFile = String.valueOf(callReq.arguments().get(FILE_PATH)); + final var errors = new RDBMSLog(logFile).getErrors(); + String results = errors.stream() + .map(RDBMSError::toJSONString) + .collect(Collectors.joining(",", "[", "]")); + return McpSchema.CallToolResult.builder() + .addTextContent(results) + .isError(false) + .build(); + })) + .build(); + } + + /** + *

+ * Builds and returns a {@link SyncToolSpecification SyncToolSpecification} for the get-rdbms-packet-dumps tool. + * This tool extracts packet dump information from a specified RDBMS/SQLNet trace file that matches a given connection ID. + * Each packet dump record includes its associated details and is serialized in JSON format. + *

+ * + * @return a {@link SyncToolSpecification SyncToolSpecification} instance for the {@code get-rdbms-packet-dumps} tool. + */ + private static SyncToolSpecification getPacketDumpsTool() { + return SyncToolSpecification.builder() + .tool(Tool.builder() + .name("get-rdbms-packet-dumps") + .title("Get RDBMS/SQLNet Packet Dumps") + .description("Extract packet dumps from RDBMS/SQLNet trace file for given connection ID.") + .inputSchema(ToolSchemas.RDBMS_TOOLS_SCHEMA) + .build()) + .callHandler((exchange, callReq) -> Utils.tryCall(() -> { + final var filePath = String.valueOf(callReq.arguments().get(FILE_PATH)); + final var connId = String.valueOf(callReq.arguments().get(CONNECTION_ID)); + final var packetDumps = new RDBMSLog(filePath).getPacketDumps(connId); + String results = packetDumps.stream() + .map(RDBMSPacketDump::toJSONString) + .collect(Collectors.joining(",", "[", "]")); + return McpSchema.CallToolResult.builder() + .addTextContent(results) + .isError(false) + .build(); + })) + .build(); + } + + /** + *

+ * A functional interface similar to {@link java.util.function.Supplier Supplier}, but allows for throwing an {@link IOException}. + *

+ * + * @param the type of results supplied by this supplier + */ + @FunctionalInterface + public interface ThrowingSupplier { + /** + * Gets a result, potentially throwing an {@link IOException}. + * + * @return a result + * @throws IOException if an I/O error occurs + */ + T get() throws IOException; + } + + +} diff --git a/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/SimilaritySearchTool.java b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/SimilaritySearchTool.java new file mode 100644 index 0000000..6f453aa --- /dev/null +++ b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/SimilaritySearchTool.java @@ -0,0 +1,169 @@ +/* + ** Oracle Database MCP Toolkit version 1.0.0 + ** + ** Copyright (c) 2025 Oracle and/or its affiliates. + ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ + */ + +package com.oracle.database.mcptoolkit.tools; + +import com.fasterxml.jackson.databind.json.JsonMapper; +import com.oracle.database.mcptoolkit.ServerConfig; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.spec.McpSchema; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; + +import static com.oracle.database.mcptoolkit.Utils.openConnection; +import static com.oracle.database.mcptoolkit.Utils.tryCall; +import static com.oracle.database.mcptoolkit.Utils.getOrDefault; + +/** + * Provides a tool for performing similarity searches using vector embeddings. + *

+ * This class is responsible for handling the "similarity_search" tool, which allows users to + * search for similar text based on a given query. + */ +public class SimilaritySearchTool { + + private static final Pattern SAFE_IDENT = Pattern.compile("[A-Za-z0-9_$.#]+"); + + private static final String DEFAULT_VECTOR_TABLE = "profile_oracle"; + private static final String DEFAULT_VECTOR_DATA_COLUMN = "text"; + private static final String DEFAULT_VECTOR_EMBEDDING_COLUMN = "embedding"; + private static final String DEFAULT_VECTOR_MODEL_NAME = "doc_model"; + private static final int DEFAULT_VECTOR_TEXT_FETCH_LIMIT = 4000; + private static final String SIMILARITY_SEARCH = """ + SELECT dbms_lob.substr(%s, %s, 1) AS text + FROM %s + ORDER BY VECTOR_DISTANCE(%s, + TO_VECTOR(VECTOR_EMBEDDING(%s USING ? AS data))) + FETCH FIRST ? ROWS ONLY + """; + + /** + * Returns a tool specification for the "similarity_search" tool. + *

+ * This tool allows users to perform similarity searches using vector embeddings. + * + * @param config server configuration + * @return tool specification + */ + public static McpServerFeatures.SyncToolSpecification getSymilaritySearchTool(ServerConfig config) { + + return McpServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder() + .name("similarity_search") + .title("Similarity Search") + .description("Semantic vector similarity over a table with (text, embedding) columns") + .inputSchema(ToolSchemas.SIMILARITY_SEARCH) + .build()) + .callHandler((exchange, callReq) -> tryCall(() -> { + try (Connection c = openConnection(config, null)) { + Map arguments = callReq.arguments(); + String question = String.valueOf(arguments.get("question")); + if (question == null || question.isBlank()) { + return new McpSchema.CallToolResult("Question must be non-blank", true); + } + int topK; + try { + topK = Integer.parseInt(String.valueOf(arguments.getOrDefault("topK", 5))); + } catch (NumberFormatException e) { + topK = 5; + } + topK = Math.max(1, Math.min(100, topK)); + + String table = getOrDefault(arguments.get("table"), DEFAULT_VECTOR_TABLE); + String dataColumn = getOrDefault(arguments.get("dataColumn"), DEFAULT_VECTOR_DATA_COLUMN); + String embeddingColumn = getOrDefault(arguments.get("embeddingColumn"), DEFAULT_VECTOR_EMBEDDING_COLUMN); + String modelName = getOrDefault(arguments.get("modelName"), DEFAULT_VECTOR_MODEL_NAME); + + int textFetchLimit = DEFAULT_VECTOR_TEXT_FETCH_LIMIT; + Object limitArg = arguments.get("textFetchLimit"); + if (limitArg != null) { + try { + textFetchLimit = Math.max(1, Integer.parseInt(String.valueOf(limitArg))); + } + catch (NumberFormatException ignored) {} + } + + List results = runSimilaritySearch( + c, table, dataColumn, embeddingColumn, modelName, textFetchLimit, question, topK); + + return McpSchema.CallToolResult.builder() + .structuredContent(Map.of("rows", results)) + .addTextContent(new JsonMapper().writeValueAsString(results)) + .build(); + } + })) + .build(); + } + + + /** + * Executes a vector similarity search against the configured table. + * + *

Uses the columns/table/model declared in {@link ServerConfig} and returns the + * text fragments of the top matches.

+ * + * @param c an open JDBC connection + * @param table table name containing text + embedding columns + * @param dataColumn column holding the text/CLOB to return + * @param embeddingColumn vector column used by the similarity function + * @param modelName database vector model used to embed the question + * @param textFetchLimit substring length to return from the text column + * @param question natural-language query text + * @param topK maximum number of rows to return (clamped by caller) + * @return list of text snippets ranked by similarity + * @throws java.sql.SQLException if the SQL execution fails + */ + private static List runSimilaritySearch(Connection c, + String table, + String dataColumn, + String embeddingColumn, + String modelName, + int textFetchLimit, + String question, + int topK) throws SQLException { + String sql = String.format( + SIMILARITY_SEARCH, + quoteIdent(dataColumn), textFetchLimit, quoteIdent(table), embeddingColumn, modelName + ); + + List result = new ArrayList<>(); + try (PreparedStatement ps = c.prepareStatement(sql)) { + ps.setString(1, question); + ps.setInt(2, topK); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + result.add(rs.getString("text")); + } + } + } + return result; + } + + + /** + * Escapes and quotes a potentially unsafe identifier for SQL use. + * + * @param ident identifier to quote + * @return a quoted or validated identifier + */ + static String quoteIdent(String ident) { + if (ident == null) throw new IllegalArgumentException("identifier is null"); + String s = ident.trim(); + if (!SAFE_IDENT.matcher(s).matches()) { + return "\"" + s.replace("\"", "\"\"") + "\""; + } + return s; + } + +} diff --git a/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/ToolSchemas.java b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/ToolSchemas.java new file mode 100644 index 0000000..0ef145d --- /dev/null +++ b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/ToolSchemas.java @@ -0,0 +1,159 @@ +/* + ** Oracle Database MCP Toolkit version 1.0.0 + ** + ** Copyright (c) 2025 Oracle and/or its affiliates. + ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ + */ + +package com.oracle.database.mcptoolkit.tools; + +/** + * The ToolSchemas class provides a collection of JSON schemas for various tool-related operations. + * These schemas define the structure and constraints of the input data for different tools. + */ +public class ToolSchemas { + + /** + * JSON schema for SQL-only operations. + *

+ * This schema requires a "sql" property and optionally accepts a "txId" property. + */ + static final String SQL_ONLY = """ + { + "type":"object", + "properties": { + "sql": { + "type": "string" + }, + "txId": { + "type": "string", + "description": "Optional active transaction id" + } + }, + "required":["sql"] + }"""; + + /** + * JSON schema for file path operations. + *

+ * This schema requires a "filePath" property, which should be an absolute path or a URL to an Oracle JDBC log file. + */ + static final String FILE_PATH_SCHEMA = """ + { + "type": "object", + "properties": { + "filePath": { + "type": "string", + "description": "Absolute path or an URL to the Oracle JDBC log file." + } + }, + "required": ["filePath"] + } + """; + + /** + * JSON schema for file comparison operations. + *

+ * This schema requires "filePath" and "secondFilePath" properties, which should be absolute paths or URLs to Oracle JDBC log files. + */ + static final String FILE_COMPARISON_SCHEMA = """ + { + "type": "object", + "properties": { + "filePath": { + "type": "string", + "description": "Absolute path or an URL to the 1st Oracle JDBC log file" + }, + "secondFilePath": { + "type": "string", + "description": "Absolute path or an URL to the 2nd Oracle JDBC log file" + } + }, + "required": ["filePath", "secondFilePath"] + } + """; + + /** + * JSON schema for RDBMS tools operations. + *

+ * This schema requires "filePath" and "connectionId" properties, where "filePath" is an absolute path or a URL to an RDBMS/SQLNet trace file, and "connectionId" is a connection ID string. + */ + static final String RDBMS_TOOLS_SCHEMA = """ + { + "type": "object", + "properties": { + "filePath": { + "type": "string", + "description": "Absolute path or an URL to the RDBMS/SQLNet trace file" + }, + "connectionId": { + "type": "string", + "description": "Connection ID string" + } + }, + "required": ["filePath", "connectionId"] + } + """; + + /** + * JSON schema for similarity search operations. + *

+ * This schema requires a "question" property and optionally accepts several other properties to customize the search. + */ + static final String SIMILARITY_SEARCH = """ + { + "type": "object", + "properties": { + "question": { + "type": "string", + "description": "Natural-language query text" + }, + "topK": { + "type": "integer", + "description": "Number of rows to return", + "default": 5 + }, + "table": { + "type": "string", + "description": "Override: table name" + }, + "dataColumn": { + "type": "string", + "description": "Override: text/CLOB column" + }, + "embeddingColumn": { + "type": "string", + "description": "Override: embedding column" + }, + "modelName": { + "type": "string", + "description": "Override: vector model name" + }, + "textFetchLimit": { + "type": "integer", + "description": "Override: substring length (CLOB)" } + }, + "required": ["question"] + }"""; + + /** + * JSON schema for explain plan operations. + *

+ * This schema requires a "sql" property and optionally accepts several other properties to customize the plan. + */ + static final String EXPLAIN_PLAN = """ + { + "type": "object", + "properties": { + "sql": { "type": "string", "description": "SQL to plan" }, + "mode": { "type": "string", "enum": ["static","dynamic"], "description": "static=EXPLAIN PLAN, dynamic=DISPLAY_CURSOR" }, + "maxRows": { "type": "integer", "minimum": 1, "description": "When executing SELECT in dynamic mode, cap rows fetched" }, + "execute": { "type": "boolean", "description": "If true, actually run the SQL to collect runtime stats (A-Rows). Default: SELECT=true, DML/DDL=false" }, + "xplanOptions": { + "type": "string", + "description": "Override DBMS_XPLAN options, e.g. 'ALLSTATS LAST +PEEKED_BINDS +OUTLINE +PROJECTION'" + } + }, + "required": ["sql"] + }"""; +} diff --git a/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/web/AuthorizationFilter.java b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/web/AuthorizationFilter.java new file mode 100644 index 0000000..d94e046 --- /dev/null +++ b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/web/AuthorizationFilter.java @@ -0,0 +1,106 @@ +/* + ** Oracle Database MCP Toolkit version 1.0.0 + ** + ** Copyright (c) 2025 Oracle and/or its affiliates. + ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ + */ + +package com.oracle.database.mcptoolkit.web; + +import com.oracle.database.mcptoolkit.oauth.OAuth2Configuration; +import com.oracle.database.mcptoolkit.oauth.OAuth2TokenValidator; +import jakarta.servlet.Filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.ServletRequest; +import jakarta.servlet.ServletResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; + +/** + * The AuthorizationFilter class is a servlet filter that authenticates incoming requests + * by verifying the presence and validity of an OAuth2 access token in the Authorization header. + *

+ * If OAuth2 authentication is enabled (as determined by OAuth2Configuration), this filter + * checks the Authorization header for a Bearer token and validates it using an instance of + * OAuth2TokenValidator. If the token is invalid or missing, it returns a 401 Unauthorized response. + *

+ *

+ * The filter delegates to the next filter in the chain if the token is valid or if OAuth2 authentication + * is disabled. + *

+ */ +public class AuthorizationFilter implements Filter { + /** + * Validator instance used to verify the validity of OAuth2 access tokens. + */ + private static final OAuth2TokenValidator VALIDATOR = new OAuth2TokenValidator(); + + /** + * Intercepts incoming requests to authenticate them based on the presence and validity of an OAuth2 access token. + *

+ * If OAuth2 authentication is enabled, it checks the Authorization header for a Bearer token and validates it. + * If the token is invalid or missing, it returns a 401 Unauthorized response. Otherwise, it delegates to the next filter in the chain. + *

+ * + * @param request the servlet request + * @param response the servlet response + * @param chain the filter chain + * @throws IOException if an I/O error occurs during the filtering process + * @throws ServletException if the filter chain fails + */ + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + if (OAuth2Configuration.getInstance().isAuthenticationEnabled()) { + final HttpServletRequest httpRequest = (HttpServletRequest) request; + final HttpServletResponse httpResponse = (HttpServletResponse) response; + + final String authHeader = httpRequest.getHeader("Authorization"); + if (authHeader == null || !authHeader.startsWith("Bearer ")) { + handleError(httpResponse, httpRequest); + return; + } + + final String token = authHeader.substring("Bearer ".length()).trim(); + if (!VALIDATOR.isTokenValid(token)) { + handleError(httpResponse, httpRequest); + return; + } + } + + // token is valid + chain.doFilter(request, response); + } + + /** + * Handles authentication errors by returning a 401 Unauthorized response with a WWW-Authenticate header + * and a JSON payload containing error details. + * + * @param httpResponse the HTTP response + * @param httpRequest the HTTP request + * @throws IOException if an I/O error occurs while writing the response + */ + private void handleError(HttpServletResponse httpResponse, HttpServletRequest httpRequest) throws IOException { + final String serverURL = WebUtils.buildURLFromRequest(httpRequest); + final var resourceMetadataURL = serverURL + "/.well-known/oauth-protected-resource"; + + httpResponse.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + httpResponse.setHeader("WWW-Authenticate", + "Bearer error=\"invalid_request\", " + + "error_description=\"Access token is invalid or not provided in the request\", " + + "resource_metadata=\"" + resourceMetadataURL + "\""); + final String json = """ + { + "error": "invalid_request", + "error_description": "Access token is invalid or not provided in the request", + "resource_metadata": "%s" + } + """.formatted(resourceMetadataURL); + httpResponse.getWriter() + .write(json); + } + +} diff --git a/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/web/RedirectOAuthToOpenIDServlet.java b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/web/RedirectOAuthToOpenIDServlet.java new file mode 100644 index 0000000..761192a --- /dev/null +++ b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/web/RedirectOAuthToOpenIDServlet.java @@ -0,0 +1,46 @@ +/* + ** Oracle Database MCP Toolkit version 1.0.0 + ** + ** Copyright (c) 2025 Oracle and/or its affiliates. + ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ + */ + +package com.oracle.database.mcptoolkit.web; + +import com.oracle.database.mcptoolkit.oauth.OAuth2Configuration; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; + +/** + * Servlet responsible for redirecting OAuth requests to the OpenID configuration endpoint. + *

+ * This servlet handles HTTP GET requests and redirects the client to the OpenID configuration endpoint + * specified in the OAuth2 configuration. + */ +public class RedirectOAuthToOpenIDServlet extends HttpServlet { + + /** + * Handles HTTP GET requests by redirecting the client to the OpenID configuration endpoint. + *

+ * The redirect URL is constructed using the authentication server URL from the OAuth2 configuration. + *

+ * The response includes an "Access-Control-Allow-Origin" header with the allowed hosts. + * + * @param request the HTTP request + * @param response the HTTP response + * @throws ServletException if a servlet-specific error occurs + * @throws IOException if an I/O error occurs + */ + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + final String redirectLink = OAuth2Configuration.getInstance().getAuthServer() + + "/.well-known/openid-configuration"; + + response.addHeader("Access-Control-Allow-Origin", WebUtils.getAllowedHosts()); + response.sendRedirect(redirectLink); + } +} diff --git a/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/web/WebUtils.java b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/web/WebUtils.java new file mode 100644 index 0000000..cf35f42 --- /dev/null +++ b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/web/WebUtils.java @@ -0,0 +1,83 @@ +/* + ** Oracle Database MCP Toolkit version 1.0.0 + ** + ** Copyright (c) 2025 Oracle and/or its affiliates. + ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ + */ + +package com.oracle.database.mcptoolkit.web; + +import com.oracle.database.mcptoolkit.LoadedConstants; +import jakarta.servlet.http.HttpServletRequest; + +import java.util.Objects; + +/** + * Utility class for web-related operations, such as reading web-related system properties. + * This class is not intended to be instantiated and provides only static methods. + */ +public class WebUtils { + + private WebUtils() {} + + /** + * Builds a URL string from the given {@link HttpServletRequest}, considering + * forwarded protocol headers and default port handling. + *

+ * The method first checks for the {@code X-Forwarded-Proto} header to determine + * the schema ({@code http} or {@code https}). + * If the header is missing, empty, or invalid, it falls back to the request's scheme. + * It then appends the server name and, if necessary, the port number + * (omitting defaults: 80 for http, 443 for https). + *

+ * + * @param request the {@link HttpServletRequest} from which to build the URL; must not be null + * @return a string representing the constructed URL in the format {@code scheme://serverName[:port]} + * @throws NullPointerException if the request parameter is null + */ + static String buildURLFromRequest(final HttpServletRequest request) { + Objects.requireNonNull(request, "request cannot be null"); + + final StringBuilder url = new StringBuilder(); + + String schema = request.getHeader("X-Forwarded-Proto"); + if (schema == null || schema.isEmpty() || + !("http".equalsIgnoreCase(schema) || "https".equalsIgnoreCase(schema))) + schema = request.getScheme(); + + url.append(schema) + .append("://") + .append(request.getServerName()); + + final int port = request.getServerPort(); + + if (!("http".equals(request.getScheme()) && port == 80) && + !("https".equals(request.getScheme()) && port == 443)) + url.append(":").append(port); + + return url.toString(); + } + + /** + * Retrieves the value of the system property {@code allowedHosts}. + * If the property is not set, it defaults to {@code "*"}. + * + * @return the value of the {@code allowedHosts} system property, or {@code "*"} if not set + */ + static String getAllowedHosts() { + return LoadedConstants.ALLOWED_HOSTS; + } + + /** + * Checks if redirection from OpenID to OAuth is enabled by examining the + * system property {@code redirectOpenIDToOAuth}. If the property is set to + * "true" (case-insensitive), the method returns {@code true}; otherwise, + * it returns {@code false}. If the property is not set, it defaults to {@code false}. + * + * @return {@code true} if redirection from OpenID to OAuth is enabled, {@code false} otherwise + */ + public static boolean isRedirectOpenIDToOAuthEnabled() { + return Boolean.parseBoolean(LoadedConstants.REDIRECT_OPENID_TO_OAUTH); + } + +} diff --git a/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/web/WellKnownServlet.java b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/web/WellKnownServlet.java new file mode 100644 index 0000000..2338569 --- /dev/null +++ b/src/oracle-db-mcp-toolkit/src/main/java/com/oracle/database/mcptoolkit/web/WellKnownServlet.java @@ -0,0 +1,63 @@ +/* + ** Oracle Database MCP Toolkit version 1.0.0 + ** + ** Copyright (c) 2025 Oracle and/or its affiliates. + ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ + */ + +package com.oracle.database.mcptoolkit.web; + +import com.oracle.database.mcptoolkit.oauth.OAuth2Configuration; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServlet; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +import java.io.IOException; + +/** + * The WellKnownServlet class handles HTTP GET requests to the well-known endpoint, + * providing information about the OAuth2 configuration and MCP endpoint. + * + */ +public class WellKnownServlet extends HttpServlet { + /** + * The OAuth2 configuration instance. + */ + private static final OAuth2Configuration OAUTH2_CONFIG = OAuth2Configuration.getInstance(); + + /** + * Handles HTTP GET requests to the well-known endpoint. + * + * If OAuth2 is not configured or authentication is disabled, returns a 204 No Content response. + * Otherwise, returns a JSON response with the MCP endpoint and authorization server URL. + * + * @param request the HTTP request + * @param response the HTTP response + * @throws ServletException if a servlet-specific error occurs + * @throws IOException if an I/O error occurs + */ + @Override + protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { + if (!OAUTH2_CONFIG.isOAuth2Configured() || !OAUTH2_CONFIG.isAuthenticationEnabled()) { + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + return; + } + + response.setContentType("application/json"); + response.addHeader("Access-Control-Allow-Origin", WebUtils.getAllowedHosts()); + response.setStatus(HttpServletResponse.SC_OK); + + final String serverURL = WebUtils.buildURLFromRequest(request); + final String mcpEndpoint = serverURL + "/mcp"; + String authServer = WebUtils.isRedirectOpenIDToOAuthEnabled() ? serverURL : OAUTH2_CONFIG.getAuthServer(); + + final String json = """ + { + "resource":"%s", + "authorization_servers":["%s"] + }""".formatted(mcpEndpoint, authServer); + response.getWriter() + .write(json); + } +} diff --git a/src/oracle-db-mcp-toolkit/src/test/java/com/oracle/database/mcptoolkit/OracleJDBCLogAnalyzerTest.java b/src/oracle-db-mcp-toolkit/src/test/java/com/oracle/database/mcptoolkit/OracleJDBCLogAnalyzerTest.java new file mode 100644 index 0000000..1c2cc40 --- /dev/null +++ b/src/oracle-db-mcp-toolkit/src/test/java/com/oracle/database/mcptoolkit/OracleJDBCLogAnalyzerTest.java @@ -0,0 +1,89 @@ +package com.oracle.database.mcptoolkit; + +import com.oracle.database.mcptoolkit.tools.LogAnalyzerTools; +import io.modelcontextprotocol.server.McpServerFeatures.SyncToolSpecification; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.util.Map; +import java.util.stream.Collectors; + +import static java.util.function.Function.identity; +import static org.junit.jupiter.api.Assertions.*; + +@SuppressWarnings("unchecked") +class OracleJDBCLogAnalyzerTest { + + private static Map tools; + + @BeforeAll + static void initializeTools(){ + tools = LogAnalyzerTools.getTools() + .stream() + .map(SyncToolSpecification::tool) + .collect(Collectors.toMap(McpSchema.Tool::name, identity())); + } + + @ParameterizedTest + @ValueSource(strings = { + "get-jdbc-stats", + "get-jdbc-queries", + "get-jdbc-errors", + "list-log-files-from-directory", + "get-jdbc-connection-events", + "jdbc-log-comparison", + "get-rdbms-errors", + "get-rdbms-packet-dumps" + }) + void testToolPresence(final String toolName) { + final var isToolPresent = tools.containsKey(toolName); + assertTrue(isToolPresent, toolName + " tool should be present."); + } + + @Test + void testFilePathParameterInAllTools() { + for (var tool : tools.values()) { + final var properties = tool.inputSchema().properties(); + assertTrue(properties.containsKey("filePath"), "Every tool " + + "should have filePath parameter"); + + final var filePathProperty = (Map) properties.get("filePath"); + assertEquals("string", filePathProperty.get("type")); + } + } + + @Test + void testSecondFilePathParameterInLogComparisonTool() { + final var toolProperties = tools.get("jdbc-log-comparison") + .inputSchema() + .properties(); + + final var isSecondFileParameterPresent = toolProperties.containsKey("filePath"); + assertTrue(isSecondFileParameterPresent, "log-comparison tool " + + "should have 'secondFilePath' parameter."); + + final var secondFilePathProperty = (Map) toolProperties.get("secondFilePath"); + + assertEquals("string", secondFilePathProperty.get("type"), + "The type of 'filePath' and 'secondFilePath' parameters should be 'string'"); + } + + @Test + void testConnectionIdParameterInGetPacketDumpsTool() { + final var toolProperties = tools.get("get-rdbms-packet-dumps") + .inputSchema() + .properties(); + + final var isConnectionIdPresent = toolProperties.containsKey("connectionId"); + assertTrue(isConnectionIdPresent, "log-comparison tool " + + "should have 'connectionId' parameter."); + + var connectionIdProperty = (Map) toolProperties.get("connectionId"); + + assertEquals("string", connectionIdProperty.get("type"), + "The type of 'filePath' and 'secondFilePath' parameters should be 'string'"); + } +} \ No newline at end of file