diff --git a/src/oracle-db-mcp-java-toolkit/DEMO.md b/src/oracle-db-mcp-java-toolkit/DEMO.md index 136dc768..e7d0e109 100644 --- a/src/oracle-db-mcp-java-toolkit/DEMO.md +++ b/src/oracle-db-mcp-java-toolkit/DEMO.md @@ -2,8 +2,9 @@ ## 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: +This document demonstrates how to try the Oracle Database MCP Toolkit by running your own instance of the MCP server. + +The demo focuses on the following tools: JDBC log analysis tools: @@ -12,12 +13,12 @@ JDBC log analysis tools: - **`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: +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): +Custom tools (via YAML configuration): - **`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: @@ -40,33 +41,85 @@ tools: statement: SELECT * FROM hotels WHERE name LIKE '%' || :name || '%' ``` -Where `${db_url}`, `${user}` and `${password}`are environment variables. +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. +- An MCP client that supports the Streamable HTTP transport (e.g., MCP Inspector, Cline, Claude Desktop). Stdio is also + supported by the server; see the README for details. +- A running MCP Toolkit server. **Note**: If you're using Claude Desktop, you also need [mcp-remote](https://www.npmjs.com/package/mcp-remote). -## 3. Setup +## 3. Start a local MCP Toolkit server (HTTP example) -The deployed instance uses `streamableHttp` transport protocol and a runtime generated `Authorization` token. +You can run the server over HTTPS with authentication enabled. The token can be supplied via the +`ORACLE_DB_TOOLKIT_AUTH_TOKEN` environment variable or, if not set, it will be generated and printed to the logs (see README §4.4). -Use the following token `3e297077-f01e-4045-a9d0-2a71e97e6dfa`. +Example: -### MCP Inspector +```bash +java \ + -Dtransport=http \ + -Dhttps.port=45450 \ + -DcertificatePath=/path/to/your-certificate.p12 \ + -DcertificatePassword=yourPassword \ + -DenableAuthentication=true \ + -Dtools=get-jdbc-stats,get-jdbc-queries,get-jdbc-errors,jdbc-log-comparison,get-rdbms-errors,get-rdbms-packet-dumps \ + -jar /oracle-db-mcp-toolkit-1.0.0.jar +``` + +This exposes the MCP endpoint at: `https://localhost:45450/mcp`. + +When connecting from a client, include the token in the `Authorization` header as `Bearer YOUR_TOKEN`. + +For additional deployment modes (including stdio and Docker/Podman) and OAuth2 configuration, see the project README. + +### Run in a container (Podman) + +The repository contains a Dockerfile you can use to build and run the server in a container. + +1) Build the image (from the repo root): + +```bash +podman build -t oracle-db-mcp-toolkit:1.0.0 . +``` + +2) Run the container with HTTPS and token auth (adjust paths and secrets for your environment): + +```bash +podman run --name with_token_auth -d \ + -p 45453:45453 \ + -v /home/opc/jetty.p12:/app/jetty.p12:ro,z \ + -v /home/opc/custom_tools/custom_tools.yaml:/app/custom_tools.yaml:ro,z \ + -e JAVA_TOOL_OPTIONS="-Dtransport=http -Dhttps.port=45453 -DcertificatePath=/app/jetty.p12 -DcertificatePassword=CERTIF_PASSWORD -DenableAuthentication=true -Dtools=mcp-admin,log-analyzer,rag -DconfigFile=/app/custom_tools.yaml" \ + -e ORACLE_DB_TOOLKIT_AUTH_TOKEN="3e297077-f01e-4045-a9d0-2a71e97e6dfa" \ + oracle-db-mcp-toolkit:1.0.0 +``` + +After the container starts, the MCP endpoint is available at: + + https://localhost:45453/mcp + +Notes: -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: +- The :z volume flag is commonly required on SELinux-enabled hosts (Podman). It is harmless elsewhere. +- For Docker, you can use the same command but omit :z on volume mounts. + +## 4. Connect your MCP client + +### MCP Inspector -MCP Inspector config screenshot +Configure MCP Inspector with: -After checking the configuration, click the *Connect* button, and the available tools will be shown in the main section: +- Transport: `streamableHttp` +- URL: `https://localhost:45450/mcp` +- Connection Type: Via Proxy +- Authentication: Add a custom header `Authorization: Bearer YOUR_TOKEN` -MCP Inspector tools screenshot +After saving, click Connect and the available tools will be listed. -_Note :_ The filePath should be provided as a URL. +_Note:_ For log analysis tools, provide `filePath` values as URLs where applicable. ### Cline @@ -80,22 +133,14 @@ Add or merge this configuration into `cline_mcp_settings.json`: "disabled": false, "timeout": 60, "type": "streamableHttp", - "url": "https://mcptoolkit.orcl.dev:45453/mcp", - "headers": { - "Authorization": "Bearer 3e297077-f01e-4045-a9d0-2a71e97e6dfa" - } + "url": "https://localhost:45450/mcp", + "headers": { "Authorization": "Bearer YOUR_TOKEN" } } } } ``` -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 +After saving, the tools will appear in the MCP Servers settings. You can then invoke tools like `get-jdbc-queries` by prompt. ### Claude Desktop @@ -110,22 +155,14 @@ Below is an example of `claude_desktop_config.json` file: "args": [ "-y", "mcp-remote", - "https://mcptoolkit.orcl.dev:45453/mcp", + "https://localhost:45450/mcp", "--header", "Authorization:${DEMO_TOKEN}" ], - "env": { - "DEMO_TOKEN": "Bearer 3e297077-f01e-4045-a9d0-2a71e97e6dfa" - } + "env": { "DEMO_TOKEN": "Bearer YOUR_TOKEN" } } } } ``` -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 +Upon saving, open Claude Desktop and you should see the tools in the Connectors section. \ No newline at end of file diff --git a/src/oracle-db-mcp-java-toolkit/Dockerfile b/src/oracle-db-mcp-java-toolkit/Dockerfile index 2034a156..3871967e 100644 --- a/src/oracle-db-mcp-java-toolkit/Dockerfile +++ b/src/oracle-db-mcp-java-toolkit/Dockerfile @@ -1,8 +1,8 @@ # ---------- 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 +ARG MAVEN_VERSION=3.9.12 +ARG MAVEN_BASE_URL=https://archive.apache.org/dist/maven/maven-3 RUN curl -fsSL \ ${MAVEN_BASE_URL}/${MAVEN_VERSION}/binaries/apache-maven-${MAVEN_VERSION}-bin.tar.gz \ diff --git a/src/oracle-db-mcp-java-toolkit/README.md b/src/oracle-db-mcp-java-toolkit/README.md index 2cf3d0da..1eb4c4ab 100644 --- a/src/oracle-db-mcp-java-toolkit/README.md +++ b/src/oracle-db-mcp-java-toolkit/README.md @@ -7,7 +7,9 @@ Oracle Database MCP Toolkit is a Model Context Protocol (MCP) server that lets y * 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. + * Database tools for SQL execution, table management, transactions, performance monitoring and execution plan analysis. + * Database-powered tools, including vector similarity search (RAG). + * Admin tools for runtime discovery and configuration: list available tools and live-edit YAML-defined tools with hot reload. * Deploy locally or remotely - optionally as a container - with support for TLS and OAuth2 ![MCP Toolkit Architecture Diagram](./images/MCPToolkitArchitectureDiagram.svg) @@ -25,7 +27,7 @@ This provides a flexible and declarative way to extend the server without modify 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. + * `url`: This is 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. @@ -80,6 +82,10 @@ tools: description: Hotel name to search for. required: false statement: SELECT * FROM hotels WHERE name LIKE '%' || :name || '%' + +# Optional toolsets combining custom tools +toolsets: + reporting: [hotels-by-name] ``` To enable YAML configuration, launch the server with: @@ -88,11 +94,102 @@ To enable YAML configuration, launch the server with: java -DconfigFile=/path/to/config.yaml -jar .jar ``` +Toolsets can be enabled from `-Dtools` alongside individual tools. For example: +- `-Dtools=reporting` enables all tools in the `reporting` toolset +- `-Dtools=reporting,explain` enables your `reporting` set plus the built-in `explain` toolset (see below) +- `-Dtools=*` or omit `-Dtools` to enable everything + +> Tip: You can also manage YAML-defined tools at runtime using the `edit-tools` admin tool; see section 3.9. + --- ## 3. Built-in Tools -### 3.1. Oracle JDBC Log Analysis +### Built-in Toolsets Overview +The server provides four built-in toolsets that can be enabled via `-Dtools`: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ToolsetDescriptionTools Included
mcp-adminServer discovery and runtime configuration + list-tools, edit-tools +
log-analyzerJDBC and RDBMS log analysis + get-jdbc-stats, get-jdbc-queries, get-jdbc-errors, + get-jdbc-connection-events, list-log-files-from-directory, + jdbc-log-comparison, get-rdbms-errors, get-rdbms-packet-dumps +
database-operatorDatabase operations, transactions, monitoring, and execution plans + read-query, write-query, create-table, delete-table, + list-tables, describe-table, start-transaction, resume-transaction, + commit-transaction, rollback-transaction, db-ping, + db-metrics-range, explain-plan +
ragVector similarity searchsimilarity-search
+ +_Note: Each tool belongs to exactly one built-in toolset. Enabling a toolset enables all tools listed for that toolset._ + +**Common Configurations:** +- `-Dtools=mcp-admin` - Admin and runtime configuration tools +- `-Dtools=log-analyzer` - Log analysis only (no database required) +- `-Dtools=database-operator` - Database operations and SQL execution +- `-Dtools=rag` – Vector similarity search +- `-Dtools=mcp-admin,log-analyzer` - Admin + log analysis +- `-Dtools=*` - All tools (default if omitted) + +### 3.1. Database Operations +These tools provide direct SQL execution capabilities: + +- **`read-query`**: Execute SELECT-only queries and return results as JSON. +- **`write-query`**: Execute DML/DDL operations (INSERT, UPDATE, DELETE, CREATE, etc.) with autocommit. + +### 3.2. Table Management +These tools help you manage database tables: + +- **`create-table`**: Create a table using full CREATE TABLE statements +- **`delete-table`**: Drop an existing table by name +- **`list-tables`**: List all tables and synonyms in the current schema +- **`describe-table`**: Get detailed column information for any table + +### 3.3. Transaction Management +These tools provide fine-grained control over database transactions: + +- **`start-transaction`**: Begin a new JDBC transaction and get a transaction ID +- **`resume-transaction`**: Verify if a transaction ID is still active +- **`commit-transaction`**: Commit and close a transaction +- **`rollback-transaction`**: Rollback and close a transaction + +### 3.4. Database Monitoring +These tools help monitor database health and performance: + +- **`db-ping`**: Connectivity + timings (connect/round-trip) + Database metadata +- **`db-metrics-range`**: Retrieve Oracle performance metrics from V$SYSSTAT + +### 3.5. Oracle JDBC Log Analysis These tools operate on Oracle JDBC thin client logs: @@ -103,16 +200,16 @@ These tools operate on Oracle JDBC thin client logs: * **`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: +### 3.6. 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 +### 3.7. Vector Similarity Search (RAG) -* **`similarity_search`**: Perform semantic similarity search using Oracle’s vector features (`VECTOR_EMBEDDING`, `VECTOR_DISTANCE`). +* **`similarity-search`**: Perform semantic similarity search using Oracle’s vector features (`VECTOR_EMBEDDING`, `VECTOR_DISTANCE`). **Inputs:** @@ -128,9 +225,9 @@ These tools operate on RDBMS/SQLNet trace files: * JSON array of similar rows with scores and truncated snippets. -### 3.4. SQL Execution Plan Analysis +### 3.8. SQL Execution Plan Analysis -* **`explain_plan`**: Generate Oracle execution plans and receive a pre-formatted LLM prompt for tuning and explanation. +* **`explain-plan`**: Generate Oracle execution plans and receive a pre-formatted LLM prompt for tuning and explanation. **Modes:** @@ -153,6 +250,49 @@ These tools operate on RDBMS/SQLNet trace files: * `planText`: DBMS_XPLAN output. * `llmPrompt`: A structured prompt for an LLM to explain + tune the plan. +### 3.9. Admin and Runtime Configuration Tools + +These tools help you discover what's enabled and manage YAML-defined tools at runtime. +They are part of the `mcp-admin` toolset (enable via `-Dtools=mcp-admin` or include individual tool names). + +_Note: The `mcp-admin` toolset is focused on server discovery and runtime configuration only._ + +#### MCP Admin Tools: + +- `list-tools`: List all available tools with their descriptions. + - Inputs: none + - Returns: `tools` array with `{ name, title, description }` for built-ins (honoring `-Dtools` filter) and any YAML-defined tools. + +- `edit-tools`: Create, update, or remove a YAML-defined tool. Changes are auto-reloaded by the server. + - Inputs (subset; see schema in code): + - `name` (string, required): Tool name/YAML key + - `remove` (boolean, optional): If true, delete the tool + - `description` (string, optional) + - `dataSource` (string, optional): Key from `dataSources:` + - `statement` (string, optional): SQL (SELECT or DML) + - `parameters` (array, optional): Items of `{ name, type, description, required }` + - Requirements and behavior: + - Requires `-DconfigFile` to be set to a writable YAML file; otherwise the tool will return an error. + - On upsert/remove, the YAML is written and the server hot-reloads the configuration shortly after. + + Example (upsert a tool): + ```jsonc + { + "name": "hotels-by-rating", + "description": "List hotels with a minimum rating", + "dataSource": "prod-db", + "statement": "SELECT * FROM hotels WHERE rating >= :minRating ORDER BY rating DESC", + "parameters": [ + { "name": "minRating", "type": "number", "description": "Minimum rating", "required": true } + ] + } + ``` + + Example (remove a tool): + ```jsonc + { "name": "hotels-by-rating", "remove": true } + ``` + --- ## 4. Installation @@ -400,11 +540,18 @@ Ultimately, the token must be included in the http request header (e.g. `Authori 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. + Comma-separated allow-list of tool or toolset names to enable (case-insensitive).
+ You can pass individual tools (e.g. get-jdbc-stats, read-query) or any of the following built-in toolsets: +
    +
  • mcp-admin — server discovery and runtime configuration tools (list-tools, edit-tools)
  • +
  • database-operator — database operations, transactions, monitoring, and execution plans (read-query, write-query, create-table, delete-table, list-tables, describe-table, start-transaction, resume-transaction, commit-transaction, rollback-transaction, db-ping, db-metrics-range, explain-plan).
  • +
  • log-analyzer — all JDBC log and RDBMS/SQLNet analysis tools (get-jdbc-stats, get-jdbc-queries, get-jdbc-errors, list-log-files-from-directory, jdbc-log-comparison, get-jdbc-connection-events, get-rdbms-errors, get-rdbms-packet-dumps)
  • +
  • rag — similarity-search
  • +
+ You can also define your own YAML toolsets: and reference them here. + Use * or all to enable everything. If omitted, all tools are enabled by default. - get-jdbc-stats,get-jdbc-queries + mcp-admin,log-analyzer or reporting ojdbc.ext.dir @@ -450,7 +597,7 @@ Ultimately, the token must be included in the http request header (e.g. `Authori configFile No - Path to a YAML file defining datasources and tools. + Path to a YAML file defining datasources and tools. Required if you intend to use the edit-tools admin tool to persist changes. /opt/mcp/config.yaml @@ -545,6 +692,10 @@ podman run --rm \ This exposes the MCP endpoint at: https://[your-ip-address]:45451/mcp +If you plan to use the `edit-tools` admin tool inside the container, mount a writable config file and set `-DconfigFile` accordingly, for example: +- Mount: `-v /absolute/path/config.yaml:/config/config.yaml:Z` +- Set: `-DconfigFile=/config/config.yaml` + 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 diff --git a/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/OracleDatabaseMCPToolkit.java b/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/OracleDatabaseMCPToolkit.java index c47243ca..07ba5d26 100644 --- a/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/OracleDatabaseMCPToolkit.java +++ b/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/OracleDatabaseMCPToolkit.java @@ -28,7 +28,15 @@ import org.apache.tomcat.util.net.SSLHostConfigCertificate; import java.io.File; +import java.time.Duration; import java.util.concurrent.TimeUnit; +import java.nio.file.FileSystems; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardWatchEventKinds; +import java.nio.file.WatchEvent; +import java.nio.file.WatchKey; +import java.nio.file.WatchService; import java.util.logging.Logger; import static com.oracle.database.mcptoolkit.Utils.installExternalExtensionsFromDir; @@ -41,6 +49,8 @@ public class OracleDatabaseMCPToolkit { private static final Logger LOG = Logger.getLogger(OracleDatabaseMCPToolkit.class.getName()); static ServerConfig config; + private static volatile McpSyncServer serverInstance; + static { config = Utils.loadConfig(); @@ -49,14 +59,12 @@ public class OracleDatabaseMCPToolkit { public static void main(String[] args) { installExternalExtensionsFromDir(); - McpSyncServer server; - switch (LoadedConstants.TRANSPORT_KIND) { case "http" -> { - server = startHttpServer(); + serverInstance = startHttpServer(); } case "stdio" -> { - server = McpServer + serverInstance = McpServer .sync(new StdioServerTransportProvider(new ObjectMapper())) .serverInfo("oracle-db-mcp-toolkit", "1.0.0") .capabilities(McpSchema.ServerCapabilities.builder() @@ -69,7 +77,11 @@ public static void main(String[] args) { default -> throw new IllegalArgumentException( "Unsupported transport: " + LoadedConstants.TRANSPORT_KIND + " (expected 'stdio' or 'http')"); } - Utils.addSyncToolSpecifications(server, config); + Utils.addSyncToolSpecifications(serverInstance, config); + + Thread pollingThread = new Thread(() -> pollConfigFile(), "config-file-poller"); + pollingThread.setDaemon(true); + pollingThread.start(); } private OracleDatabaseMCPToolkit() { @@ -82,10 +94,11 @@ private OracleDatabaseMCPToolkit() { private static McpSyncServer startHttpServer() { try { HttpServletStreamableServerTransportProvider transport = - HttpServletStreamableServerTransportProvider.builder() - .objectMapper(new ObjectMapper()) - .mcpEndpoint("/mcp") - .build(); + HttpServletStreamableServerTransportProvider.builder() + .objectMapper(new ObjectMapper()) + .keepAliveInterval(Duration.ofSeconds(60)) + .mcpEndpoint("/mcp") + .build(); McpSyncServer server = McpServer .sync(transport) @@ -128,7 +141,7 @@ private static McpSyncServer startHttpServer() { if (LoadedConstants.HTTPS_PORT == null || LoadedConstants.KEYSTORE_PATH == null || LoadedConstants.KEYSTORE_PASSWORD == null) throw new RuntimeException("SSL setup failed: HTTPS port, Keystore path or password not specified"); - enableHttps(tomcat, LoadedConstants.KEYSTORE_PATH, LoadedConstants.KEYSTORE_PASSWORD); + enableHttps(tomcat); tomcat.start(); @@ -143,11 +156,9 @@ private static McpSyncServer startHttpServer() { * 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) { + private static void enableHttps(Tomcat tomcat) { try { // Create HTTPS connector Connector https = new Connector("org.apache.coyote.http11.Http11NioProtocol"); @@ -155,8 +166,6 @@ private static void enableHttps(Tomcat tomcat, String keystorePath, String keyst https.setSecure(true); https.setScheme("https"); https.setProperty("SSLEnabled", "true"); - // - // Create SSL config SSLHostConfig sslHostConfig = new SSLHostConfig(); @@ -167,8 +176,8 @@ private static void enableHttps(Tomcat tomcat, String keystorePath, String keyst SSLHostConfigCertificate.Type.RSA ); - cert.setCertificateKeystoreFile(keystorePath); - cert.setCertificateKeystorePassword(keystorePassword); + cert.setCertificateKeystoreFile(LoadedConstants.KEYSTORE_PATH); + cert.setCertificateKeystorePassword(LoadedConstants.KEYSTORE_PASSWORD); cert.setCertificateKeystoreType("PKCS12"); // Attach certificate to SSL config @@ -185,5 +194,47 @@ private static void enableHttps(Tomcat tomcat, String keystorePath, String keyst } } + public static ServerConfig getConfig() { + return config; + } + + /** + * Exposes the running McpSyncServer instance for admin operations (e.g., removing tools at runtime). + */ + public static McpSyncServer getServer() { + return serverInstance; + } + private static void reloadConfigAndResetTools() { + try { + LOG.info(()->"[DEBUG] Reloading config..."); + ServerConfig newConfig = Utils.loadConfig(); + LOG.info(()->"[DEBUG] Old custom tool names: " + Utils.customToolNames); + LOG.info(()->"[DEBUG] New config tool names: " + newConfig.tools.keySet()); + config = newConfig; // update reference + Utils.reloadCustomTools(serverInstance, newConfig); + LOG.info(()->"[DEBUG] Reloaded config and refreshed tools."); + } catch (Exception e) { + System.err.println("[oracle-db-mcp-toolkit] Failed to reload config: " + e); + } + } + + // For now, we rely on this instead of the nio watcher logic (for container’s sake) + private static void pollConfigFile() { + File configFile = new File(LoadedConstants.CONFIG_FILE); + long lastModified = configFile.lastModified(); + while (true) { + long nowModified = configFile.lastModified(); + if (nowModified != lastModified && nowModified != 0) { + lastModified = nowModified; + reloadConfigAndResetTools(); + } + try { + Thread.sleep(2000); // Check every 2 seconds + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + break; + } + } + } } diff --git a/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/ServerConfig.java b/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/ServerConfig.java index 98d91cd3..b75765ff 100644 --- a/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/ServerConfig.java +++ b/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/ServerConfig.java @@ -11,12 +11,9 @@ 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; +import java.util.*; +import java.util.logging.Logger; +import java.util.logging.Level; /** * Immutable server configuration loaded from system properties. @@ -30,12 +27,15 @@ *
    *
  • {@code db.user}
  • *
  • {@code db.password}
  • - *
  • {@code tools} — comma-separated allow-list of tool names; {@code *} or {@code all} enables all.
  • + *
  • {@code tools} — comma-separated allow-list of tool names or toolset + * names; (e.g., {@code log_analyzer}, {@code admin}, {@code explain}, + * {@code rag}); {@code *} or {@code all} enables all.
  • *
* *

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

*/ public final class ServerConfig { + private static final Logger LOG = Logger.getLogger(ServerConfig.class.getName()); public final String dbUrl; public final String dbUser; public final char[] dbPassword; @@ -61,7 +61,32 @@ private ServerConfig( } private static final Set DB_TOOLS = Set.of( - "similarity_search", "explain_plan" + "similarity-search", "explain-plan", + "read-query", "write-query", "create-table", "delete-table", + "list-tables", "describe-table", "start-transaction", "resume-transaction", + "commit-transaction", "rollback-transaction", "db-ping", "db-metrics-range" + ); + + /** Built-in toolsets covering predefined tools. Lowercase keys and members. */ + private static final Map> BUILTIN_TOOLSETS = Map.of( + "log-analyzer", Set.of( + "get-jdbc-stats", + "get-jdbc-queries", + "get-jdbc-errors", + "list-log-files-from-directory", + "jdbc-log-comparison", + "get-jdbc-connection-events", + "get-rdbms-errors", + "get-rdbms-packet-dumps" + ), + "rag", Set.of("similarity-search"), + "database-operator", Set.of( + "read-query", "write-query", "create-table", "delete-table", + "list-tables", "describe-table", "start-transaction", "resume-transaction", + "commit-transaction", "rollback-transaction", "db-ping", "db-metrics-range", + "explain-plan" + ), + "mcp-admin", Set.of("list-tools", "edit-tools") ); @@ -83,8 +108,7 @@ private ServerConfig( * @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); + Set rawTools = parseToolsProp(LoadedConstants.TOOLS); String dbUrl = LoadedConstants.DB_URL; String dbUser = LoadedConstants.DB_USER; @@ -98,7 +122,10 @@ public static ServerConfig fromSystemPropertiesAndYaml(ConfigRoot configRoot, St entry.getValue().name = entry.getKey(); } } - configRoot.substituteEnvVars(); + if (configRoot != null) configRoot.substituteEnvVars(); + + Set expandedTools = expandToolsFilter(rawTools, configRoot); + boolean allLoadedConstantsPresent = dbUrl != null && !dbUrl.isBlank() && dbUser != null && !dbUser.isBlank() @@ -112,6 +139,8 @@ public static ServerConfig fromSystemPropertiesAndYaml(ConfigRoot configRoot, St defaultSourceName = defaultSourceKey; } + boolean needDb = wantsAnyDbToolsExpanded(expandedTools); + if (needDb && (dbUrl == null || dbUrl.isBlank())) { throw new IllegalStateException("Missing required db.url in both system properties and YAML config"); } @@ -121,7 +150,7 @@ public static ServerConfig fromSystemPropertiesAndYaml(ConfigRoot configRoot, St 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); + return new ServerConfig(dbUrl, dbUser, dbPass, expandedTools, sources, toolsMap); } /** @@ -132,8 +161,9 @@ public static ServerConfig fromSystemPropertiesAndYaml(ConfigRoot configRoot, St * @throws IllegalStateException if {@code db.url} is missing or blank */ static ServerConfig fromSystemProperties() { - Set tools = parseToolsProp(LoadedConstants.TOOLS); - boolean needDb = wantsAnyDbTools(tools); + Set raw = parseToolsProp(LoadedConstants.TOOLS); + Set expanded = expandToolsFilter(raw, null); + boolean needDb = wantsAnyDbToolsExpanded(expanded); String dbUrl = LoadedConstants.DB_URL; if (needDb && (dbUrl == null || dbUrl.isBlank())) { @@ -144,7 +174,7 @@ static ServerConfig fromSystemProperties() { dbUrl, LoadedConstants.DB_USER, LoadedConstants.DB_PASSWORD, - tools, + expanded, new HashMap<>(), new HashMap<>() ); @@ -157,7 +187,7 @@ static ServerConfig fromSystemProperties() { * “enable every tool” and returns {@code null}. * * Examples: - * "similarity_search,explain_plan" -> ["similarity_search","explain_plan"] + * "similarity-search,explain-plan" -> ["similarity-search","explain-plan"] * "*" or "all" or "" -> null (treat as all tools enabled) * * @param prop comma-separated tool names @@ -174,9 +204,52 @@ private static Set parseToolsProp(String prop) { return s; } - private static boolean wantsAnyDbTools(Set toolsFilter) { - if (toolsFilter == null) return true; // null == all tools enabled - for (String t : toolsFilter) { + /** + * Expands the raw -Dtools filter to concrete tool names by resolving YAML and built-in toolsets. + * Returns null to mean "all tools" if the input is null. + */ + private static Set expandToolsFilter(Set raw, ConfigRoot configRoot) { + if (raw == null) return null; // all tools enabled + Map> yamlSets = (configRoot != null) ? configRoot.toolsets : null; + + Set out = new LinkedHashSet<>(); + for (String name : raw) { + String k = name == null ? null : name.trim().toLowerCase(Locale.ROOT); + if (k == null || k.isEmpty()) continue; + + // Built-in toolset match first. If a YAML toolset has the same name, prefer built-in and log an error. + Set builtin = BUILTIN_TOOLSETS.get(k); + if (builtin != null) { + if (yamlSets != null && yamlSets.containsKey(k)) { + LOG.log(Level.SEVERE, () -> "Custom toolset '" + k + "' conflicts with built-in toolset; ignoring custom definition and using built-in."); + } + out.addAll(builtin); + continue; + } + + // YAML toolset match (only if not a built-in name) + if (yamlSets != null && yamlSets.containsKey(k)) { + List members = yamlSets.get(k); + if (members != null) { + for (String m : members) { + if (m != null) { + String mm = m.trim().toLowerCase(Locale.ROOT); + if (!mm.isEmpty()) out.add(mm); + } + } + } + continue; + } + + // Fallback to explicit tool name + out.add(k); + } + return out; + } + + private static boolean wantsAnyDbToolsExpanded(Set expandedFilter) { + if (expandedFilter == null) return true; // all enabled + for (String t : expandedFilter) { if (DB_TOOLS.contains(t)) return true; } return false; diff --git a/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/Utils.java b/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/Utils.java index d61c7a1c..85f717f6 100644 --- a/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/Utils.java +++ b/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/Utils.java @@ -12,9 +12,7 @@ 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 com.oracle.database.mcptoolkit.tools.*; import io.modelcontextprotocol.server.McpServerFeatures; import io.modelcontextprotocol.server.McpSyncServer; import io.modelcontextprotocol.spec.McpSchema; @@ -39,14 +37,18 @@ import java.sql.ResultSetMetaData; import java.sql.SQLException; import java.util.ArrayList; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Locale; import java.util.Map; +import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.logging.Level; import java.util.logging.Logger; +import java.util.regex.Pattern; import java.util.stream.Stream; +import java.lang.reflect.Field; /** * Utility class for managing Oracle database connections and @@ -60,76 +62,189 @@ */ public class Utils { private static final Logger LOG = Logger.getLogger(Utils.class.getName()); + private static final Pattern SAFE_IDENT = Pattern.compile("[A-Za-z0-9_$.#]+"); private static final Map dataSources = new ConcurrentHashMap<>(); private static volatile DataSource defaultDataSource; + public static final Set customToolNames = new HashSet<>(); + private static final Map customToolSignatures = new ConcurrentHashMap<>(); + /** *

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

*/ static void addSyncToolSpecifications(McpSyncServer server, ServerConfig config) { + // Clear custom tool names at startup in case of restart + synchronized (customToolNames) { + customToolNames.clear(); + customToolSignatures.clear(); + } + List specs = LogAnalyzerTools.getTools(); for (McpServerFeatures.SyncToolSpecification spec : specs) { - String toolName = spec.tool().name(); // e.g. "get-stats", "get-queries" + String toolName = spec.tool().name(); + if (isToolEnabled(config, toolName)) { + server.addTool(spec); + } + } + + // RAG tools + List ragSpecs = RagTools.getTools(config); + for (McpServerFeatures.SyncToolSpecification spec : ragSpecs) { + String toolName = spec.tool().name(); if (isToolEnabled(config, toolName)) { server.addTool(spec); } } - // similarity_search - if (isToolEnabled(config, "similarity_search")) { - server.addTool(SimilaritySearchTool.getSymilaritySearchTool(config)); + // Database Operator tools + List dbOperatorSpecs = DatabaseOperatorTools.getTools(config); + for (McpServerFeatures.SyncToolSpecification spec : dbOperatorSpecs) { + String toolName = spec.tool().name(); + if (isToolEnabled(config, toolName)) { + server.addTool(spec); + } } - // explain_plan - if (isToolEnabled(config, "explain_plan")) { - server.addTool(ExplainAndExecutePlanTool.getExplainAndExecutePlanTool(config)); + // MCP Admin tools + List mcpAdminSpecs = McpAdminTools.getTools(config); + for (McpServerFeatures.SyncToolSpecification spec : mcpAdminSpecs) { + String toolName = spec.tool().name(); + if (isToolEnabled(config, toolName)) { + server.addTool(spec); + } } // ---------- 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() - ); + if (config.tools != null) { + for (Map.Entry entry : config.tools.entrySet()) { + ToolConfig tc = entry.getValue(); + if (!isToolEnabled(config, tc.name)) continue; + server.addTool(makeSyncToolSpecification(tc, config)); + String sig = computeSignature(tc); + synchronized (customToolNames) { + customToolNames.add(tc.name); + customToolSignatures.put(tc.name, sig); + } + } + } + } + + private static String computeSignature(ToolConfig tc) { + StringBuilder sb = new StringBuilder(); + sb.append(nullToEmpty(tc.name)).append('|') + .append(nullToEmpty(tc.dataSource)).append('|') + .append(nullToEmpty(tc.description)).append('|') + .append(nullToEmpty(tc.statement)).append('|'); + if (tc.parameters != null) { + for (ToolParameterConfig p : tc.parameters) { + if (p == null) continue; + sb.append(nullToEmpty(p.name)).append(':') + .append(nullToEmpty(p.type)).append(':') + .append(nullToEmpty(p.description)).append(':') + .append(p.required).append(';'); + } + } + return sb.toString(); + } + + private static String nullToEmpty(String s) { return s == null ? "" : s; } + + /** + * Unregister a custom tool from local in-memory registries so runtime removal stays consistent. + */ + public static void unregisterCustomToolLocally(String name) { + if (name == null) return; + synchronized (customToolNames) { + customToolNames.remove(name); + try { + // Remove signature if present; safe even if missing + Field f = Utils.class.getDeclaredField("customToolSignatures"); + f.setAccessible(true); + @SuppressWarnings("unchecked") + Map sigs = (java.util.Map) f.get(null); + sigs.remove(name); + } catch (Throwable ignored) {} + } + } + + + private static McpServerFeatures.SyncToolSpecification makeSyncToolSpecification(ToolConfig tc, ServerConfig config) { + return 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(); + } + + /** + * Incrementally apply YAML tool changes: add new tools, update changed ones, and do NOT remove tools + * that are not present in the new config. + */ + public static void reloadCustomTools(McpSyncServer server, ServerConfig config) { + synchronized (customToolNames) { + if (config.tools != null) { + for (Map.Entry entry : config.tools.entrySet()) { + String name = entry.getKey(); + ToolConfig tc = entry.getValue(); + if (!isToolEnabled(config, name)) { + continue; + } + String newSig = computeSignature(tc); + + if (!customToolNames.contains(name)) { + server.addTool(makeSyncToolSpecification(tc, config)); + customToolNames.add(name); + customToolSignatures.put(name, newSig); + } else { + String oldSig = customToolSignatures.get(name); + if (oldSig == null || !oldSig.equals(newSig)) { + try { + server.removeTool(name); + } catch (Exception ex) { + LOG.info(() -> "Failed to remove tool for update: " + name + ". Exception: " + ex); + } + server.addTool(makeSyncToolSpecification(tc, config)); + customToolSignatures.put(name, newSig); + } else { + LOG.info(() -> "Tool unchanged (skipped): " + name); + } + } + } + } + // Intentionally do not remove tools absent from the new config } } @@ -163,13 +278,15 @@ static ServerConfig loadConfig() { if (yamlConfig == null) { config = ServerConfig.fromSystemProperties(); } else { - String defaultSourceKey = yamlConfig.dataSources!=null?yamlConfig.dataSources.keySet().stream().findFirst().orElse(null):null; + 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 @@ -189,7 +306,7 @@ public static Connection openConnection(ServerConfig cfg, String sourceName) thr * @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; @@ -330,7 +447,7 @@ static void installExternalExtensionsFromDir() { * @return list of rows with column:value mapping * @throws SQLException if reading from ResultSet fails */ - static List> rsToList(ResultSet rs) throws SQLException { + public static List> rsToList(ResultSet rs) throws SQLException { List> out = new ArrayList<>(); ResultSetMetaData md = rs.getMetaData(); int cols = md.getColumnCount(); @@ -378,4 +495,49 @@ private static boolean isToolEnabled(ServerConfig config, String toolName) { return config.toolsFilter.contains(key); } -} \ No newline at end of file + /** + * Checks if the provided SQL looks like a SELECT. + * + * @param sql the SQL string + * @return true if it begins with "SELECT" (case-insensitive) + */ + public static boolean looksSelect(String sql) { + return sql != null && sql.trim().regionMatches(true, 0, "SELECT", 0, 6); + } + + /** + * DDL detector (CREATE/ALTER/DROP/TRUNCATE/RENAME/COMMENT/GRANT/REVOKE). + * Used to block DDL inside user-managed transactions. + */ + public 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 "); + } + + /** + * Escapes and quotes a potentially unsafe identifier for SQL use. + * + * @param ident identifier to quote + * @return a quoted or validated identifier + */ + public 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-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/config/ConfigRoot.java b/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/config/ConfigRoot.java index 38fcca68..3c4985d8 100644 --- a/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/config/ConfigRoot.java +++ b/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/config/ConfigRoot.java @@ -7,6 +7,7 @@ package com.oracle.database.mcptoolkit.config; +import java.util.List; import java.util.Map; /** @@ -15,6 +16,15 @@ public class ConfigRoot { public Map dataSources; public Map tools; + /** + * Optional named toolsets allowing users to group custom tools and enable them with -Dtools. + * Example YAML: + *
+   * toolsets:
+   *   reporting: [top_customers, sales_by_region]
+   * 
+ */ + public Map> toolsets; /** * Substitutes environment variables in the source and tool configurations. diff --git a/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/config/ToolConfig.java b/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/config/ToolConfig.java index 2e949e9f..658fcae3 100644 --- a/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/config/ToolConfig.java +++ b/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/config/ToolConfig.java @@ -61,6 +61,17 @@ public void substituteEnvVars() { } } + /** + * Builds a JSON representation of the input schema for this tool configuration. + *

+ * The input schema is constructed based on the tool's parameter configurations. + * Each parameter is represented as a property in the schema, with its type and description. + * Required parameters are listed in the "required" section of the schema. + *

+ * The resulting JSON string can be used to validate input data for the tool. + * + * @return a JSON string representing the input schema for this tool configuration + */ public String buildInputSchemaJson() { ObjectNode schema = JsonNodeFactory.instance.objectNode(); schema.put("type", "object"); diff --git a/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/DatabaseOperatorTools.java b/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/DatabaseOperatorTools.java new file mode 100644 index 00000000..7ca19edb --- /dev/null +++ b/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/DatabaseOperatorTools.java @@ -0,0 +1,741 @@ +/* + ** Oracle Database MCP Toolkit version 1.0.0 + ** + ** Copyright (c) 2026 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.ObjectMapper; +import com.oracle.database.mcptoolkit.ServerConfig; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.spec.McpSchema; + +import java.sql.*; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static com.oracle.database.mcptoolkit.Utils.*; + +/** + * Provides a set of database operator tools for various database operations. + * + *

The available tools are:

+ *
    + *
  • read-query: Execute SELECT queries and return JSON results.
  • + *
  • write-query: Execute DML/DDL statements.
  • + *
  • create-table: Create table.
  • + *
  • delete-table: Drop table by name.
  • + *
  • list-tables: List all tables and synonyms in the current schema.
  • + *
  • describe-table: Get column details for a specific table.
  • + *
  • start-transaction: Begin a new JDBC transaction.
  • + *
  • resume-transaction: Verify a transaction ID is active.
  • + *
  • commit-transaction: Commit and close a transaction.
  • + *
  • rollback-transaction: Rollback and close a transaction.
  • + *
  • db-ping: Check database connectivity and latency.
  • + *
  • db-metrics-range: Retrieve Oracle performance metrics from V$SYSSTAT.
  • + *
  • explain-plan: Generate and explain Oracle SQL execution plans (static or dynamic).
  • + *
+ */ +public final class DatabaseOperatorTools { + + // Transaction store (txId -> Connection) + private static final Map TX = new ConcurrentHashMap<>(); + + private DatabaseOperatorTools() {} + + /** + * Returns a list of database operator tool specifications based on the provided server configuration. + * The returned tools are used for various database operations such as executing queries, managing transactions, + * and retrieving database metrics. + * + *

The tools returned include:

+ *
    + *
  • read-query: Execute SELECT queries and return JSON results.
  • + *
  • write-query: Execute DML/DDL statements.
  • + *
  • create-table: Create table.
  • + *
  • delete-table: Drop table by name.
  • + *
  • list-tables: List all tables and synonyms in the current schema.
  • + *
  • describe-table: Get column details for a specific table.
  • + *
  • start-transaction: Begin a new JDBC transaction.
  • + *
  • resume-transaction: Verify a transaction ID is active.
  • + *
  • commit-transaction: Commit and close a transaction.
  • + *
  • rollback-transaction: Rollback and close a transaction.
  • + *
  • db-ping: Check database connectivity and latency.
  • + *
  • db-metrics-range: Retrieve Oracle performance metrics from V$SYSSTAT.
  • + *
  • explain-plan: Generate and explain Oracle SQL execution plans (static or dynamic).
  • + *
+ * + *

A shutdown hook is added to clean up any active transactions when the JVM shuts down.

+ * + * @param config the server configuration to use for database connections + * @return a list of tool specifications for database operations + */ + public static List getTools(ServerConfig config) { + List tools = new ArrayList<>(); + + tools.add(getReadQueryTool(config)); + tools.add(getWriteQueryTool(config)); + tools.add(getCreateTableTool(config)); + tools.add(getDeleteTableTool(config)); + tools.add(getListTablesTool(config)); + tools.add(getDescribeTableTool(config)); + tools.add(getStartTransactionTool(config)); + tools.add(getResumeTransactionTool()); + tools.add(getCommitTransactionTool()); + tools.add(getRollbackTransactionTool()); + tools.add(getDbPingTool(config)); + tools.add(getDbMetricsTool(config)); + tools.add(getExplainAndExecutePlanTool(config)); + + // Add shutdown hook to clean up transactions + Runtime.getRuntime().addShutdownHook(new Thread(() -> { + TX.values().forEach(conn -> { + try { if (!conn.getAutoCommit()) conn.rollback(); } catch (Exception ignored) {} + try { conn.close(); } catch (Exception ignored) {} + }); + TX.clear(); + })); + + return tools; + } + + private static McpServerFeatures.SyncToolSpecification getReadQueryTool(ServerConfig config) { + return McpServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder() + .name("read-query") + .title("Read Query") + .description("Run a SELECT and return rows as JSON. (Optionally accepts txId to run inside an open transaction.)") + .inputSchema(ToolSchemas.SQL_ONLY) + .build()) + .callHandler((exchange, callReq) -> tryCall(() -> { + try (DatabaseOperatorTools.ConnLease lease = acquireConnection(config, callReq.arguments().get("txId"))) { + Connection c = lease.c; + String sql = String.valueOf(callReq.arguments().get("sql")); + if (!looksSelect(sql)) { + return new McpSchema.CallToolResult("Only SELECT is allowed", true); + } + var rows = rsToList(c.createStatement().executeQuery(sql)); + return McpSchema.CallToolResult.builder() + .structuredContent(Map.of("rows", rows, "rowCount", rows.size())) + .addTextContent(new ObjectMapper().writeValueAsString(rows)) + .build(); + } + })) + .build(); + } + + private static McpServerFeatures.SyncToolSpecification getWriteQueryTool(ServerConfig config) { + return McpServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder() + .name("write-query") + .title("Write Query") + .description("Execute DML (inside a transaction if txId is provided) or DML/DDL in autocommit mode.") + .inputSchema(ToolSchemas.SQL_ONLY) + .build()) + .callHandler((exchange, callReq) -> tryCall(() -> { + try (DatabaseOperatorTools.ConnLease lease = acquireConnection(config, callReq.arguments().get("txId"))) { + Connection c = lease.c; + String sql = String.valueOf(callReq.arguments().get("sql")); + boolean inTx = !lease.closeOnExit; + if (inTx && isDdl(sql)) { + return new McpSchema.CallToolResult( + "DDL is not allowed inside a transaction. Run this statement without txId.", true); + } + int n = execUpdate(c, sql); + return McpSchema.CallToolResult.builder() + .structuredContent(Map.of("updateCount", n)) + .addTextContent("{\"updateCount\":" + n + "}") + .build(); + } + })) + .build(); + } + + private static McpServerFeatures.SyncToolSpecification getCreateTableTool(ServerConfig config) { + return McpServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder() + .name("create-table") + .title("Create Table") + .description("Create a table from a full CREATE TABLE statement.") + .inputSchema(ToolSchemas.SQL_ONLY) + .build()) + .callHandler((exchange, callReq) -> tryCall(() -> { + try (Connection c = openConnection(config, null)) { + String sql = String.valueOf(callReq.arguments().get("sql")); + execUpdate(c, sql); + return new McpSchema.CallToolResult("OK", false); + } + })) + .build(); + } + + private static McpServerFeatures.SyncToolSpecification getDeleteTableTool(ServerConfig config) { + return McpServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder() + .name("delete-table") + .title("Drop Table") + .description("Drop a table by name.") + .inputSchema(ToolSchemas.DROP_OR_DESCRIBE_TABLE) + .build()) + .callHandler((exchange, callReq) -> tryCall(() -> { + try (Connection c = openConnection(config, null)) { + String table = String.valueOf(callReq.arguments().get("table")); + int updateCount = execUpdate(c, "DROP TABLE " + quoteIdent(table)); + return McpSchema.CallToolResult.builder() + .structuredContent(Map.of("updateCount", updateCount, "table", table)) + .addTextContent("OK") + .build(); + } + })) + .build(); + } + + private static McpServerFeatures.SyncToolSpecification getListTablesTool(ServerConfig config) { + return McpServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder() + .name("list-tables") + .title("List Tables & Synonyms") + .description("List TABLE and SYNONYM in the current schema via DatabaseMetaData (includes comments).") + .inputSchema(ToolSchemas.NO_INPUT_SCHEMA) + .build()) + .callHandler((exchange, callReq) -> tryCall(() -> { + try (Connection c = openConnection(config, null)) { + DatabaseMetaData md = c.getMetaData(); + String schema = Optional.ofNullable(c.getSchema()) + .orElseGet(() -> { + try { + return md.getUserName(); + } catch (SQLException e) { + return null; + } + }); + if (schema != null) + schema = schema.toUpperCase(Locale.ROOT); + List> tables = new ArrayList<>(); + List> synonyms = new ArrayList<>(); + try (ResultSet rs = md.getTables(null, schema, "%", new String[]{"TABLE", "SYNONYM"})) { + while (rs.next()) { + Map row = new LinkedHashMap<>(); + row.put("owner", rs.getString("TABLE_SCHEM")); + row.put("name", rs.getString("TABLE_NAME")); + row.put("kind", rs.getString("TABLE_TYPE")); + row.put("comment", rs.getString("REMARKS")); + String kind = String.valueOf(row.get("kind")); + if ("SYNONYM".equalsIgnoreCase(kind)) { + synonyms.add(row); + } else { + tables.add(row); + } + } + } + Map payload = Map.of( + "schema", schema, + "counts", Map.of("tables", tables.size(), "synonyms", synonyms.size()), + "tables", tables, + "synonyms", synonyms + ); + return McpSchema.CallToolResult.builder() + .structuredContent(payload) + .addTextContent(new ObjectMapper().writeValueAsString(payload)) + .build(); + } + })) + .build(); + } + + private static McpServerFeatures.SyncToolSpecification getDescribeTableTool(ServerConfig config) { + return McpServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder() + .name("describe-table") + .title("Describe Table") + .description("Describe columns via DatabaseMetaData. Returns COLUMN_ID, COLUMN_NAME, DATA_TYPE, DATA_LENGTH, DATA_PRECISION, DATA_SCALE, NULLABLE, COMMENTS.") + .inputSchema(ToolSchemas.DROP_OR_DESCRIBE_TABLE) + .build()) + .callHandler((exchange, callReq) -> tryCall(() -> { + try (Connection c = openConnection(config, null)) { + String table = String.valueOf(callReq.arguments().get("table")); + if (table == null || table.isBlank()) { + return new McpSchema.CallToolResult("Parameter 'table' is required", true); + } + DatabaseMetaData md = c.getMetaData(); + String schema = Optional.ofNullable(c.getSchema()) + .orElseGet(() -> { + try { + return md.getUserName(); + } catch (SQLException e) { + return null; + } + }); + if (schema != null) + schema = schema.toUpperCase(Locale.ROOT); + String tableName = table.toUpperCase(Locale.ROOT); + List> rows = new ArrayList<>(); + try (ResultSet rs = md.getColumns(null, schema, tableName, "%")) { + while (rs.next()) { + int ordinal = rs.getInt("ORDINAL_POSITION"); + String colName = rs.getString("COLUMN_NAME"); + String typeName = rs.getString("TYPE_NAME"); + int colSize = rs.getInt("COLUMN_SIZE"); + int precision = rs.getInt("COLUMN_SIZE"); + int scale = rs.getInt("DECIMAL_DIGITS"); + int nullableFlag = rs.getInt("NULLABLE"); + String remarks = rs.getString("REMARKS"); + String nullableYN = (nullableFlag == DatabaseMetaData.columnNullable) ? "Y" : "N"; + if (remarks == null) + remarks = ""; + Map row = new LinkedHashMap<>(); + row.put("COLUMN_ID", ordinal); + row.put("COLUMN_NAME", colName); + row.put("DATA_TYPE", typeName); + row.put("DATA_LENGTH", colSize); + row.put("DATA_PRECISION", (scale >= 0 && precision > 0) ? precision : null); + row.put("DATA_SCALE", (scale >= 0) ? scale : null); + row.put("NULLABLE", nullableYN); + row.put("COMMENTS", remarks); + rows.add(row); + } + } + rows.sort(Comparator.comparingInt(m -> ((Number)m.get("COLUMN_ID")).intValue())); + return McpSchema.CallToolResult.builder() + .structuredContent(Map.of("columns", rows)) + .addTextContent(new ObjectMapper().writeValueAsString(rows)) + .build(); + } + })) + .build(); + } + + private static McpServerFeatures.SyncToolSpecification getStartTransactionTool(ServerConfig config) { + return McpServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder() + .name("start-transaction") + .title("Start Transaction") + .description("Start a JDBC transaction (autoCommit=false). Returns txId.") + .inputSchema(ToolSchemas.NO_INPUT_SCHEMA) + .build()) + .callHandler((exchange, callReq) -> tryCall(() -> { + Connection c = openConnection(config, null); + c.setAutoCommit(false); + String txId = UUID.randomUUID().toString(); + TX.put(txId, c); + return McpSchema.CallToolResult.builder() + .structuredContent(Map.of("txId", txId)) + .addTextContent("{\"txId\":\"" + txId + "\"}") + .build(); + })) + .build(); + } + + private static McpServerFeatures.SyncToolSpecification getResumeTransactionTool() { + return McpServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder() + .name("resume-transaction") + .title("Resume Transaction") + .description("Verify a txId is active (no-op). Returns ok.") + .inputSchema(ToolSchemas.TX_ID) + .build()) + .callHandler((exchange, callReq) -> { + String txId = String.valueOf(callReq.arguments().get("txId")); + if (TX.containsKey(txId)) { + return new McpSchema.CallToolResult("{\"ok\":true}", false); + } + return new McpSchema.CallToolResult("Unknown txId", true); + }) + .build(); + } + + private static McpServerFeatures.SyncToolSpecification getCommitTransactionTool() { + return McpServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder() + .name("commit-transaction") + .title("Commit Transaction") + .description("Commit and close a txId.") + .inputSchema(ToolSchemas.TX_ID) + .build()) + .callHandler((exchange, callReq) -> tryCall(() -> { + String txId = String.valueOf(callReq.arguments().get("txId")); + Connection c = TX.remove(txId); + if (c == null) + return new McpSchema.CallToolResult("Unknown txId", true); + try (c) { + c.commit(); + return new McpSchema.CallToolResult("{\"ok\":true}", false); + } + })) + .build(); + } + + private static McpServerFeatures.SyncToolSpecification getRollbackTransactionTool() { + return McpServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder() + .name("rollback-transaction") + .title("Rollback Transaction") + .description("Rollback and close a txId.") + .inputSchema(ToolSchemas.TX_ID) + .build()) + .callHandler((exchange, callReq) -> tryCall(() -> { + String txId = String.valueOf(callReq.arguments().get("txId")); + Connection c = TX.remove(txId); + if (c == null) + return new McpSchema.CallToolResult("Unknown txId", true); + try (c) { + c.rollback(); + return new McpSchema.CallToolResult("{\"ok\":true}", false); + } + })) + .build(); + } + + private static McpServerFeatures.SyncToolSpecification getDbPingTool(ServerConfig config) { + return McpServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder() + .name("db-ping") + .title("DB Ping") + .description("Checks connectivity and round-trip latency; returns user, schema, DB name, and version.") + .inputSchema(ToolSchemas.NO_INPUT_SCHEMA) + .build()) + .callHandler((exchange, callReq) -> tryCall(() -> { + long connStart = System.nanoTime(); + try (Connection c = openConnection(config, null)) { + long connectMs = (System.nanoTime() - connStart) / 1_000_000L; + long rtStart = System.nanoTime(); + String user = null, schema = null, dbName = null, dbTime = null; + try (PreparedStatement ps = c.prepareStatement(SqlQueries.DB_PING_QUERY); + ResultSet rs = ps.executeQuery()) { + if (rs.next()) { + user = rs.getString(1); + schema = rs.getString(2); + dbName = rs.getString(3); + dbTime = rs.getString(4); + } + } + long roundTripMs = (System.nanoTime() - rtStart) / 1_000_000L; + DatabaseMetaData md = c.getMetaData(); + Map sc = new LinkedHashMap<>(); + sc.put("ok", true); + sc.put("connectLatencyMs", connectMs); + sc.put("roundTripMs", roundTripMs); + sc.put("dbProduct", md.getDatabaseProductName()); + sc.put("dbVersion", md.getDatabaseProductVersion()); + sc.put("user", user); + sc.put("schema", schema); + sc.put("dbName", dbName); + sc.put("dbTime", dbTime); + String text = String.format(""" + OK — connection verified + connectLatencyMs: %d + roundTripMs: %d + dbProduct: %s + dbVersion: %s + user: %s + schema: %s + dbName: %s + dbTime: %s + """, connectMs, roundTripMs, md.getDatabaseProductName(), + md.getDatabaseProductVersion(), user, schema, dbName, dbTime + ); + return McpSchema.CallToolResult.builder() + .structuredContent(sc) + .addTextContent(text) + .build(); + } + })) + .build(); + } + + private static McpServerFeatures.SyncToolSpecification getDbMetricsTool(ServerConfig config) { + return McpServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder() + .name("db-metrics-range") + .title("DB Metrics") + .description("Returns Oracle DB metrics from V$SYSSTAT (cumulative counters only).") + .inputSchema(ToolSchemas.NO_INPUT_SCHEMA) + .build()) + .callHandler((exchange, callReq) -> tryCall(() -> { + try (Connection c = openConnection(config, null); + Statement st = c.createStatement(); + ResultSet rs = st.executeQuery(SqlQueries.DB_METRICS_RANGE)) { + var rows = rsToList(rs); + Map metrics = new LinkedHashMap<>(); + StringBuilder text = new StringBuilder( + "Live metrics — V$SYSSTAT (cumulative counters; rates not available):\n" + ); + for (Map r : rows) { + String name = String.valueOf(r.get("NAME")); + Object value = r.get("VALUE"); + metrics.put(name, Map.of("value", value, "unit", "")); + text.append("- ").append(name).append(": ").append(value).append("\n"); + } + return McpSchema.CallToolResult.builder() + .structuredContent(Map.of( + "source", "V$SYSSTAT", + "note", "Cumulative since instance startup; these are totals, not per-second rates.", + "metrics", metrics)) + .addTextContent(text.toString().trim()) + .build(); + } + })) + .build(); + } + + 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(); + } + + 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"); + } + + /** + * 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(); + } + + private record ExplainResult(String planText) {} + + private static int execUpdate(Connection c, String sql) throws SQLException { + try (Statement st = c.createStatement()) { + return st.executeUpdate(sql); + } + } + + private static class ConnLease implements AutoCloseable { + final Connection c; + final boolean closeOnExit; + + ConnLease(Connection c, boolean closeOnExit) { + this.c = c; + this.closeOnExit = closeOnExit; + } + + @Override + public void close() { + if (closeOnExit) { + try { c.close(); } catch (Exception ignored) {} + } + } + } + + private static DatabaseOperatorTools.ConnLease acquireConnection(ServerConfig config, + Object txIdArg) throws SQLException { + String txId = (txIdArg == null) ? null : String.valueOf(txIdArg); + if (txId == null || txId.isBlank()) { + Connection c = openConnection(config, null); + try { + c.setAutoCommit(true); + } catch (Exception ignored) {} + return new DatabaseOperatorTools.ConnLease(c, true); + } + Connection c = TX.get(txId); + if (c == null) throw new SQLException("Unknown txId"); + return new DatabaseOperatorTools.ConnLease(c, false); + } + +} diff --git a/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/ExplainAndExecutePlanTool.java b/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/ExplainAndExecutePlanTool.java deleted file mode 100644 index 42ad06fa..00000000 --- a/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/ExplainAndExecutePlanTool.java +++ /dev/null @@ -1,296 +0,0 @@ -/* - ** 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-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/McpAdminTools.java b/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/McpAdminTools.java new file mode 100644 index 00000000..cd39a7b5 --- /dev/null +++ b/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/McpAdminTools.java @@ -0,0 +1,367 @@ +/* + ** Oracle Database MCP Toolkit version 1.0.0 + ** + ** Copyright (c) 2026 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.ObjectMapper; +import com.oracle.database.mcptoolkit.LoadedConstants; +import com.oracle.database.mcptoolkit.OracleDatabaseMCPToolkit; +import com.oracle.database.mcptoolkit.ServerConfig; +import com.oracle.database.mcptoolkit.Utils; +import io.modelcontextprotocol.server.McpServerFeatures; +import io.modelcontextprotocol.spec.McpSchema; +import org.yaml.snakeyaml.Yaml; + +import java.io.Reader; +import java.io.Writer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.*; + +/** + * Provides a set of admin/maintenance tools for various operations. + * + *

The available tools are:

+ *
    + *
  • list-tools: List all available tools with descriptions.
  • + *
  • edit-tools: Upsert a YAML-defined tool in the config file and rely on runtime reload.
  • + *
+ */ +public class McpAdminTools { + + private McpAdminTools() {} + + /** + * Returns a list of all MCP admin tool specifications, including "list-tools" and "edit-tools". + * The returned tools are filtered based on the configuration provided. + * + * @param config the server configuration used to filter the tools + * @return a list of MCP admin tool specifications + */ + public static List getTools(ServerConfig config) { + List tools = new ArrayList<>(); + + tools.add(getListToolsTool(config)); + tools.add(getEditToolsTool()); + + return tools; + } + + /** + * Returns a tool specification for the "list-tools" tool, which lists all available tools + * with their descriptions. The tool respects the tools filter configuration. + * + *

The tool returns a list of tools, including:

+ *
    + *
  • Built-in log analyzer tools
  • + *
  • RAG tools
  • + *
  • Database operator tools
  • + *
  • Custom YAML tools
  • + *
  • MCP admin tools
  • + *
+ * + *

The tool's output is filtered based on the {@link ServerConfig#toolsFilter} configuration.

+ * + * @param config the server configuration used to filter the tools + * @return a tool specification for the "list-tools" tool + */ + public static McpServerFeatures.SyncToolSpecification getListToolsTool(ServerConfig config) { + return McpServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder() + .name("list-tools") + .title("List Tools") + .description("List all available tools with their descriptions.") + .inputSchema(ToolSchemas.NO_INPUT_SCHEMA) + .build()) + .callHandler((exchange, callReq) -> { + try { + // Always use the latest runtime config to avoid requiring a rebuild + ServerConfig current = OracleDatabaseMCPToolkit.getConfig() != null ? OracleDatabaseMCPToolkit.getConfig() : config; + + List> tools = new ArrayList<>(); + + // 1) Built-in log analyzer tools (respect toolsFilter) + for (McpServerFeatures.SyncToolSpecification spec : LogAnalyzerTools.getTools()) { + String name = spec.tool().name(); + if (isEnabled(current, name)) { + tools.add(Map.of( + "name", name, + "title", spec.tool().title(), + "description", spec.tool().description() + )); + } + } + + // 2) RAG tools (respect toolsFilter) + for (McpServerFeatures.SyncToolSpecification spec : RagTools.getTools(current)) { + String name = spec.tool().name(); + if (isEnabled(current, name)) { + tools.add(Map.of( + "name", name, + "title", spec.tool().title(), + "description", spec.tool().description() + )); + } + } + + // 3) Database operator tools (respect toolsFilter) + for (McpServerFeatures.SyncToolSpecification spec : DatabaseOperatorTools.getTools(current)) { + String name = spec.tool().name(); + if (isEnabled(current, name)) { + tools.add(Map.of( + "name", name, + "title", spec.tool().title(), + "description", spec.tool().description() + )); + } + } + + // 3) Custom YAML tools (always listed if present in config) + if (current.tools != null) { + for (Map.Entry e : current.tools.entrySet()) { + var tc = e.getValue(); + tools.add(Map.of( + "name", tc.name, + "title", tc.name, + "description", tc.description + )); + } + } + + for (McpServerFeatures.SyncToolSpecification spec : McpAdminTools.getTools(current)) { + String name = spec.tool().name(); + if (isEnabled(current, name)) { + tools.add(Map.of( + "name", name, + "title", spec.tool().title(), + "description", spec.tool().description() + )); + } + } + + return McpSchema.CallToolResult.builder() + .structuredContent(Map.of("tools", tools)) + .addTextContent(new ObjectMapper().writeValueAsString(tools)) + .build(); + } catch (Exception e) { + return McpSchema.CallToolResult.builder() + .addTextContent("Unexpected: " + e.getMessage()) + .isError(true) + .build(); + } + }) + .build(); + } + + /** + * Returns a tool specification for the "edit-tools" tool, which creates or updates + * YAML-defined tools in the configuration file. The changes are auto-reloaded. + * + *

The tool accepts the following input parameters:

+ *
    + *
  • name: The name of the tool to create or update (required).
  • + *
  • remove: A boolean flag indicating whether to remove the tool (optional).
  • + *
  • description: A description of the tool (optional).
  • + *
  • dataSource: The data source for the tool (optional).
  • + *
  • statement: The SQL statement for the tool (optional).
  • + *
  • parameters: A list of parameter objects for the tool (optional). + * Each parameter object can have the following properties: + *
      + *
    • name: The name of the parameter.
    • + *
    • type: The data type of the parameter.
    • + *
    • description: A description of the parameter.
    • + *
    • required: A boolean indicating whether the parameter is required.
    • + *
    + *
  • + *
+ * + *

The tool returns a result with the following properties:

+ *
    + *
  • status: The status of the operation (e.g., "ok", "noop", "error").
  • + *
  • message: A human-readable message describing the result.
  • + *
  • configFile: The path to the configuration file.
  • + *
+ * + * @return a tool specification for the "edit-tools" tool + */ + public static McpServerFeatures.SyncToolSpecification getEditToolsTool() { + return McpServerFeatures.SyncToolSpecification.builder() + .tool(McpSchema.Tool.builder() + .name("edit-tools") + .title("Edit/Add Tools") + .description("Create or update YAML-defined tools in the config file. Changes are auto-reloaded.") + .inputSchema(ToolSchemas.EDIT_TOOL_SCHEMA) + .build()) + .callHandler((exchange, callReq) -> { + try { + final var cfgPath = LoadedConstants.CONFIG_FILE; + if (cfgPath == null || cfgPath.isBlank()) { + return McpSchema.CallToolResult.builder() + .addTextContent("Config file path (configFile) is not set. Cannot edit tools.") + .isError(true) + .build(); + } + + String name = asString(callReq.arguments().get("name")); + if (name == null || name.isBlank()) { + return McpSchema.CallToolResult.builder() + .addTextContent("'name' is required") + .isError(true) + .build(); + } + + Path path = Paths.get(cfgPath); + Yaml yaml = new Yaml(); + + // Load existing YAML into a mutable map + Map root; + if (Files.exists(path)) { + try (Reader r = Files.newBufferedReader(path)) { + Object loaded = yaml.load(r); + if (loaded instanceof Map) { + //noinspection unchecked + root = new LinkedHashMap<>((Map) loaded); + } else { + root = new LinkedHashMap<>(); + } + } + } else { + root = new LinkedHashMap<>(); + } + + // Ensure 'tools' map exists + Map tools = getOrCreateMap(root, "tools"); + + // Remove if requested + Object removeFlag = callReq.arguments().get("remove"); + boolean remove = (removeFlag instanceof Boolean b && b) || + (removeFlag != null && "true".equalsIgnoreCase(String.valueOf(removeFlag))); + if (remove) { + if (tools.remove(name) != null) { + root.put("tools", tools); + try (Writer w = Files.newBufferedWriter(path)) { + yaml.dump(root, w); + } + // Try to remove the tool from the running server as well + try { + OracleDatabaseMCPToolkit.getServer().removeTool(name); + Utils.unregisterCustomToolLocally(name); + } catch (Exception ignored) {} + + return McpSchema.CallToolResult.builder() + .structuredContent(Map.of( + "status", "ok", + "message", "Tool '" + name + "' removed from YAML. Reload will occur shortly.", + "configFile", cfgPath + )) + .addTextContent("{\"status\":\"ok\",\"removed\":\"" + name + "\"}") + .build(); + } else { + return McpSchema.CallToolResult.builder() + .structuredContent(Map.of( + "status", "noop", + "message", "Tool '" + name + "' not found; nothing to remove.", + "configFile", cfgPath + )) + .addTextContent("{\"status\":\"noop\",\"missing\":\"" + name + "\"}") + .build(); + } + } + + // Upsert tool entry + Map t = getOrCreateMap(tools, name); + + // Optional fields + putIfPresent(t, callReq.arguments(), "description"); + putIfPresent(t, callReq.arguments(), "dataSource"); + putIfPresent(t, callReq.arguments(), "statement"); + + // parameters: array of objects + Object paramsObj = callReq.arguments().get("parameters"); + if (paramsObj instanceof List list) { + List> params = new ArrayList<>(); + for (Object o : list) { + if (o instanceof Map m) { + Map p = new LinkedHashMap<>(); + copyIfPresent(m, p, "name"); + copyIfPresent(m, p, "type"); + copyIfPresent(m, p, "description"); + copyIfPresent(m, p, "required"); + params.add(p); + } + } + t.put("parameters", params); + } + + tools.put(name, t); + root.put("tools", tools); + + // Write back YAML + try (Writer w = Files.newBufferedWriter(path)) { + yaml.dump(root, w); + } + + // Try to remove stale instance at runtime (best-effort hot update); server will re-add on reload + try { + var srv = OracleDatabaseMCPToolkit.getServer(); + if (srv != null) srv.removeTool(name); + } catch (Exception ignored) {} + + + // The config poller will pick up the change and hot-reload within ~2 seconds. + return McpSchema.CallToolResult.builder() + .structuredContent(Map.of( + "status", "ok", + "message", "Tool '" + name + "' upserted into YAML. Reload will occur shortly.", + "configFile", cfgPath + )) + .addTextContent("{\"status\":\"ok\",\"name\":\"" + name + "\"}") + .build(); + } catch (Exception e) { + + return McpSchema.CallToolResult.builder() + .addTextContent("Unexpected: " + e.getMessage()) + .isError(true) + .build(); + } + }) + .build(); + } + + private static boolean isEnabled(ServerConfig config, String toolName) { + if (config.toolsFilter == null) return true; + String key = toolName.toLowerCase(Locale.ROOT); + return config.toolsFilter.contains(key); + } + + private static String asString(Object v) { + return v == null ? null : String.valueOf(v); + } + + @SuppressWarnings("unchecked") + private static Map getOrCreateMap(Map parent, String key) { + Object o = parent.get(key); + if (o instanceof Map m) { + return new LinkedHashMap<>((Map) m); + } + Map created = new LinkedHashMap<>(); + parent.put(key, created); + return created; + } + + private static void putIfPresent(Map target, Map source, String key) { + Object v = source.get(key); + if (v != null) target.put(key, v); + } + + private static void copyIfPresent(Map src, Map dst, String key) { + Object v = src.get(key); + if (v != null) dst.put(key, v); + } + +} diff --git a/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/RagTools.java b/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/RagTools.java new file mode 100644 index 00000000..9c615dea --- /dev/null +++ b/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/RagTools.java @@ -0,0 +1,172 @@ +/* + ** 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 static com.oracle.database.mcptoolkit.Utils.*; +import static com.oracle.database.mcptoolkit.tools.ToolSchemas.SIMILARITY_SEARCH; + +/** + * RAG (Retrieval-Augmented Generation) tools. + *

+ * This class provides tools for RAG applications, including similarity search + * using vector embeddings. + */ +public class RagTools { + + 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 RagTools() {} + + /** + * Returns a list of all RAG tool specifications based on the provided server configuration. + *

+ * The returned list includes tool specifications for RAG applications, such as similarity search + * using vector embeddings. The tools are filtered based on the configuration settings. + * + * @param config the server configuration to use for determining which tools to include + * @return a list of tool specifications for RAG tools + */ + public static List getTools(ServerConfig config) { + List tools = new ArrayList<>(); + tools.add(getSimilaritySearchTool(config)); + return tools; + } + + /** + * Returns a tool specification for the {@code similarity-search} tool. + *

+ * This tool allows users to perform similarity searches using vector embeddings. + * The tool's behavior is configured based on the provided server configuration. + *

+ * The tool accepts the following input arguments: + *

    + *
  • {@code question}: the natural-language query text (required, non-blank)
  • + *
  • {@code topK}: the maximum number of rows to return (optional, default=5, clamped to [1, 100])
  • + *
  • {@code table}: the table name containing text + embedding columns (optional, default="profile_oracle")
  • + *
  • {@code dataColumn}: the column holding the text/CLOB to return (optional, default="text")
  • + *
  • {@code embeddingColumn}: the vector column used by the similarity function (optional, default="embedding")
  • + *
  • {@code modelName}: the database vector model used to embed the question (optional, default="doc_model")
  • + *
  • {@code textFetchLimit}: the substring length to return from the text column (optional, default=4000)
  • + *
+ *

+ * The tool returns a list of text snippets ranked by similarity, along with a structured content map containing the results. + * + * @param config the server configuration to use for determining the tool's behavior + * @return a tool specification for the {@code similarity-search} tool + */ + public static McpServerFeatures.SyncToolSpecification getSimilaritySearchTool(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(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( + SqlQueries.SIMILARITY_SEARCH_QUERY, + 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; + } + +} diff --git a/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/SimilaritySearchTool.java b/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/SimilaritySearchTool.java deleted file mode 100644 index 6f453aa3..00000000 --- a/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/SimilaritySearchTool.java +++ /dev/null @@ -1,169 +0,0 @@ -/* - ** 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-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/SqlQueries.java b/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/SqlQueries.java new file mode 100644 index 00000000..44e9c9d6 --- /dev/null +++ b/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/SqlQueries.java @@ -0,0 +1,50 @@ +/* + ** 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; + +/** + * Centralized SQL queries used by JDBC admin and other database tools. + */ +final class SqlQueries { + + /** Query template for vector similarity search. **/ + public static final String SIMILARITY_SEARCH_QUERY = """ + 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 + """; + + /** Query for fetching session user, schema, DB name, and time. */ + public static final String DB_PING_QUERY = """ + SELECT SYS_CONTEXT('USERENV','SESSION_USER') AS u, + SYS_CONTEXT('USERENV','CURRENT_SCHEMA') AS s, + SYS_CONTEXT('USERENV','DB_NAME') AS d, + TO_CHAR(SYSDATE,'YYYY-MM-DD HH24:MI:SS') AS t + FROM dual + """; + + /** Query for fetching selected metrics. */ + public static final String DB_METRICS_RANGE = """ + SELECT name, value + FROM v$sysstat + WHERE name IN ( + 'session logical reads', + 'db block gets', + 'consistent gets', + 'physical reads', + 'physical writes', + 'redo size' + ) + """; + + private SqlQueries() { + // Utility class + } +} \ No newline at end of file diff --git a/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/ToolSchemas.java b/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/ToolSchemas.java index 0ef145dd..95a9cc00 100644 --- a/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/ToolSchemas.java +++ b/src/oracle-db-mcp-java-toolkit/src/main/java/com/oracle/database/mcptoolkit/tools/ToolSchemas.java @@ -33,6 +33,33 @@ public class ToolSchemas { "required":["sql"] }"""; + /** + * JSON schema for DROP/DESCRIBE table operations. + */ + static final String DROP_OR_DESCRIBE_TABLE = """ + { + "type":"object", + "properties": + { + "table": + { + "type":"string" + }}, + "required":["table"] + }"""; + + /** + * JSON schema for transaction ID operations. + */ + static final String TX_ID = """ + { + "type":"object", + "properties": + {"txId": + {"type":"string"}}, + "required":["txId"] + }"""; + /** * JSON schema for file path operations. *

@@ -156,4 +183,47 @@ public class ToolSchemas { }, "required": ["sql"] }"""; + + /** + * JSON schema for admin tools that take no input. + */ + static final String NO_INPUT_SCHEMA = """ + { + "type": "object", + "properties": {} + } + """; + + /** + * JSON schema for editing or adding a dynamic tool in the YAML config. + * The operation is an upsert keyed by the tool name. Only provided fields are updated. + */ + static final String EDIT_TOOL_SCHEMA = """ + { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Tool name (YAML key). Required for upsert or delete." }, + "remove": { "type": "boolean", "description": "If true, remove this tool from the YAML config. Other fields are ignored." }, + "description": { "type": "string", "description": "Human-friendly description of the tool" }, + "dataSource": { "type": "string", "description": "Reference key from dataSources to use for this tool" }, + "statement": { "type": "string", "description": "SQL statement to execute (SELECT or DML)" }, + "parameters": { + "type": "array", + "description": "Optional parameter list for the tool", + "items": { + "type": "object", + "properties": { + "name": { "type": "string", "description": "Parameter name" }, + "type": { "type": "string", "description": "JSON schema type (e.g., string, number, integer, boolean)" }, + "description": { "type": "string", "description": "Parameter description" }, + "required": { "type": "boolean", "description": "Whether this parameter is required" } + }, + "required": ["name", "type"] + } + } + }, + "required": ["name"] + } + """; + }