Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ src/
│ ├── execute-sql.ts # SQL execution handler
│ ├── search-objects.ts # Unified search/list with progressive disclosure
│ ├── explain-sql.ts # Opt-in EXPLAIN plan tool (never executes the target statement)
│ └── health-check.ts # Opt-in connection pool + buffer cache metrics tool (Postgres/MySQL/MariaDB/SQL Server)
│ └── health-check.ts # Opt-in connection pool + buffer cache metrics tool (Postgres/MySQL/MariaDB/SQL Server/Oracle)
├── utils/ # Shared utilities
│ ├── dsn-obfuscator.ts# DSN security
│ ├── response-formatter.ts # Output formatting
Expand Down
11 changes: 6 additions & 5 deletions dbhub.toml.example
Original file line number Diff line number Diff line change
Expand Up @@ -272,11 +272,12 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp"

# 'health_check' is opt-in only, like explain_sql. It reports operational
# metrics (connection pool state, buffer cache hit ratio) for the source.
# Currently implemented for PostgreSQL, MySQL, MariaDB, and SQL Server;
# enabling it on other database types returns an "unsupported" error rather
# than partial data. On MySQL/MariaDB, idle-in-transaction detection
# Currently implemented for PostgreSQL, MySQL, MariaDB, SQL Server, and
# Oracle; enabling it on other database types returns an "unsupported" error
# rather than partial data. On MySQL/MariaDB, idle-in-transaction detection
# additionally requires the PROCESS privilege; on SQL Server, both metrics
# require VIEW SERVER STATE (VIEW DATABASE STATE on Azure SQL Database).
# require VIEW SERVER STATE (VIEW DATABASE STATE on Azure SQL Database); on
# Oracle, both require SELECT_CATALOG_ROLE (or SELECT ANY DICTIONARY).
# Without the right grant, the tool still returns what it can and adds a
# `notes` entry explaining the reduced visibility instead of failing
# outright. Always read-only, no readonly/max_rows options of its own.
Expand Down Expand Up @@ -420,7 +421,7 @@ dsn = "postgres://postgres:postgres@localhost:5432/myapp"
# readonly = true # Restrict to SELECT, SHOW, DESCRIBE, EXPLAIN (works for execute_sql and custom tools)
# max_rows = 1000 # Limit result set size (works for execute_sql and custom tools)
# explain_sql # Opt-in tool (name = "explain_sql"); no readonly/max_rows options - always safe
# health_check # Opt-in tool (name = "health_check"); PostgreSQL/MySQL/MariaDB/SQL Server for now, always safe
# health_check # Opt-in tool (name = "health_check"); PostgreSQL/MySQL/MariaDB/SQL Server/Oracle for now, always safe
#
# Parameter Placeholders by Database:
# PostgreSQL: $1, $2, $3
Expand Down
7 changes: 4 additions & 3 deletions docs/tools/health-check.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ Report operational health metrics for a database source: connection pool state a

- **Connection pool state**: Total/active/idle session counts, idle-in-transaction sessions, the configured connection ceiling, and how long the longest-running query or idle-in-transaction session has been open
- **Buffer cache hit ratio**: Percentage of reads served from cache vs disk, useful for spotting an undersized cache before it becomes a production incident
- **Per-engine support**: Implemented for PostgreSQL, MySQL, MariaDB, and SQL Server. SQLite has no connection pool or cache-hit concept to report, and Oracle is not implemented yet, so `health_check` returns an `UNSUPPORTED` error there
- **Graceful degradation**: On MySQL/MariaDB/SQL Server, some metrics require an elevated privilege the connected user may not have. Rather than failing outright, `health_check` returns whatever it can and adds a `notes` entry explaining what's missing
- **Per-engine support**: Implemented for PostgreSQL, MySQL, MariaDB, SQL Server, and Oracle. SQLite has no connection pool or cache-hit concept to report, so `health_check` returns an `UNSUPPORTED` error there
- **Graceful degradation**: On MySQL/MariaDB/SQL Server/Oracle, some metrics require an elevated privilege the connected user may not have. Rather than failing outright, `health_check` returns whatever it can and adds a `notes` entry explaining what's missing
Comment thread
tianzhou marked this conversation as resolved.
- **Opt-in only**: Not part of the default tool pair — must be explicitly enabled per source

<Note>
Expand Down Expand Up @@ -48,7 +48,7 @@ Call the tool with no arguments — metrics are always for the source the tool i

### Reduced-privilege output

On MySQL, MariaDB, and SQL Server, some metrics need a privilege the connected user might not have — the tool still returns what it can and explains the gap instead of erroring:
On MySQL, MariaDB, SQL Server, and Oracle, some metrics need a privilege the connected user might not have — the tool still returns what it can and explains the gap instead of erroring:

```json Example output (MySQL, without PROCESS privilege)
{
Expand All @@ -68,6 +68,7 @@ On MySQL, MariaDB, and SQL Server, some metrics need a privilege the connected u
| --- | --- | --- |
| MySQL / MariaDB | `PROCESS` | Idle-in-transaction detection only; connection/buffer-cache counts are still returned, but without it, connection visibility is restricted to the caller's own sessions (and the diagnostic session itself is excluded), so counts may under-report down to 0 |
| SQL Server | `VIEW SERVER STATE` (`VIEW DATABASE STATE` on Azure SQL Database) | Both connection pool and buffer cache sections |
| Oracle | `SELECT_CATALOG_ROLE` (or `SELECT ANY DICTIONARY`) | Both sections: connection pool from `V$SESSION` / `V$PARAMETER`, buffer cache from `V$SYSSTAT` |
| PostgreSQL | None | All metrics are available to any connected user |

## Enabling health_check
Expand Down
2 changes: 1 addition & 1 deletion docs/tools/overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ title: "Overview"
| Execute SQL | `execute_sql` or `execute_sql_{id}` | On | Execute single or multiple SQL statements (separated by semicolons) |
| Search Objects | `search_objects` or `search_objects_{id}` | On | Search and list database objects (schemas, tables, columns, procedures, indexes) with pattern matching and token-efficient progressive disclosure |
| Explain SQL | `explain_sql` or `explain_sql_{id}` | Opt-in | Show the execution plan for a SQL statement without running it |
| Health Check | `health_check` or `health_check_{id}` | Opt-in | Report connection pool state and buffer cache hit ratio (PostgreSQL, MySQL, MariaDB, SQL Server) |
| Health Check | `health_check` or `health_check_{id}` | Opt-in | Report connection pool state and buffer cache hit ratio (PostgreSQL, MySQL, MariaDB, SQL Server, Oracle) |
| Custom Tools | User-defined names | Opt-in | Define reusable, parameterized SQL operations in your `dbhub.toml` configuration file |

## Tool Configuration
Expand Down
2 changes: 1 addition & 1 deletion skills/dbhub/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ DBHub provides two MCP tools by default, plus opt-in ones:
| `search_objects` | Explore database structure — schemas, tables, columns, indexes, procedures, functions |
| `execute_sql` | Run SQL statements against the database |
| `explain_sql` (opt-in) | Show a query's execution plan without running it — only present if the source's config enables it |
| `health_check` (opt-in) | Report connection pool state and buffer cache hit ratio — only present if the source's config enables it; PostgreSQL, MySQL, MariaDB, and SQL Server only |
| `health_check` (opt-in) | Report connection pool state and buffer cache hit ratio — only present if the source's config enables it; PostgreSQL, MySQL, MariaDB, SQL Server, and Oracle only |

If multiple databases are configured, DBHub registers separate tools for each source (for example, `search_objects_prod_pg`, `execute_sql_staging_mysql`). Select the desired database by calling the correspondingly named tool.

Expand Down
63 changes: 63 additions & 0 deletions src/connectors/__tests__/oracle.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,69 @@ describe('Oracle Connector Integration Tests', () => {
});
});

describe('Oracle-specific: health check', () => {
// The image gives SYSTEM the same password as the application user, so
// the test can shape privileges deterministically: grant the app user
// the catalog role (privileged path) and create a user without it
// (restricted path).
const RESTRICTED_USER = 'dbhub_restricted';
let restricted: Connector;

beforeAll(async () => {
const admin = new OracleConnector();
await admin.connect(oracleTest.connectionString.replace(`${APP_USER}:${APP_PASSWORD}@`, `system:${APP_PASSWORD}@`));
try {
await admin.executeSQL(`GRANT SELECT_CATALOG_ROLE TO ${APP_USER}`, {});
await admin.executeSQL(`CREATE USER ${RESTRICTED_USER} IDENTIFIED BY "${APP_PASSWORD}"`, {});
await admin.executeSQL(`GRANT CREATE SESSION TO ${RESTRICTED_USER}`, {});
} finally {
await admin.disconnect();
}

// A fresh pool so the new role applies to every session it opens.
await oracleTest.connector.disconnect();
await oracleTest.connector.connect(oracleTest.connectionString);

restricted = new OracleConnector();
await restricted.connect(oracleTest.connectionString.replace(`${APP_USER}:${APP_PASSWORD}@`, `${RESTRICTED_USER}:${APP_PASSWORD}@`));
});

afterAll(async () => {
await restricted?.disconnect();
});

it('reports connection pool state and buffer cache hit ratio with SELECT_CATALOG_ROLE', async () => {
const health = await oracleTest.connector.getHealthCheck!();
expect(health.notes).toBeUndefined();

expect(health.connections).toBeDefined();
expect(health.connections!.total).toBeGreaterThanOrEqual(0);
expect(health.connections!.active).toBeGreaterThanOrEqual(0);
expect(health.connections!.idle).toBeGreaterThanOrEqual(0);
expect(health.connections!.active + health.connections!.idle).toBeLessThanOrEqual(health.connections!.total);
expect(health.connections!.idleInTransaction).toBeGreaterThanOrEqual(0);
expect(health.connections!.idleInTransactionAborted).toBeUndefined();
expect(health.connections!.maxConnections).toBeGreaterThan(0);

expect(health.bufferCache).toBeDefined();
expect(health.bufferCache!.blocksHit + health.bufferCache!.blocksRead).toBeGreaterThan(0);
expect(health.bufferCache!.hitRatioPct).not.toBeNull();
expect(health.bufferCache!.hitRatioPct).toBeGreaterThanOrEqual(0);
expect(health.bufferCache!.hitRatioPct).toBeLessThanOrEqual(100);
});

it('degrades to notes, not an error, without the catalog role', async () => {
const health = await restricted.getHealthCheck!();

expect(health.connections).toBeUndefined();
expect(health.bufferCache).toBeUndefined();
expect(health.notes).toEqual([
expect.stringContaining('V$SESSION'),
expect.stringContaining('V$SYSSTAT'),
]);
});
});

describe('Oracle-specific: EXPLAIN', () => {
it('returns an execution plan for a bare EXPLAIN without executing the statement', async () => {
const result = await oracleTest.connector.executeSQL('EXPLAIN SELECT * FROM users WHERE id = 1', { readonly: true });
Expand Down
99 changes: 99 additions & 0 deletions src/connectors/oracle/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ import {
StoredProcedure,
ExecuteOptions,
ConnectorConfig,
HealthCheckResult,
} from "../interface.js";
import { computeHitRatioPct, toNullableNumber } from "../health-check-utils.js";
import { SafeURL } from "../../utils/safe-url.js";
import { obfuscateDSNPassword } from "../../utils/dsn-obfuscate.js";
import { SQLRowLimiter } from "../../utils/sql-row-limiter.js";
Expand Down Expand Up @@ -516,6 +518,103 @@ export class OracleConnector implements Connector {
}
}

async getHealthCheck(): Promise<HealthCheckResult> {
if (!this.pool) {
throw new Error("Not connected to Oracle database");
}

const notes: string[] = [];
const result: HealthCheckResult = {};

// V$SESSION / V$PARAMETER / V$SYSSTAT are readable only with
// SELECT_CATALOG_ROLE (or SELECT ANY DICTIONARY); without it Oracle
// reports ORA-00942 as if the view did not exist. Degrade per section
// instead of failing the whole health check.
try {
const [sessions, params] = await this.withConnection((connection) =>
Promise.all([
OracleConnector.fetchRows<{
TOTAL: number;
ACTIVE: number;
IDLE: number;
IDLE_IN_TRANSACTION: number;
LONGEST_IDLE_IN_TRANSACTION_SECONDS: number | null;
LONGEST_ACTIVE_QUERY_SECONDS: number | null;
}>(
connection,
// STATUS is ACTIVE (running a call), INACTIVE (idle), or one of
// the transitional states KILLED / SNIPED / CACHED, which count
// toward the total but are neither active nor idle. TADDR is
// non-null while the session has an open transaction;
// LAST_CALL_ET is seconds since the current call began (ACTIVE)
// or since the last call ended (otherwise).
`SELECT
COUNT(*) AS total,
SUM(CASE WHEN status = 'ACTIVE' THEN 1 ELSE 0 END) AS active,
SUM(CASE WHEN status = 'INACTIVE' THEN 1 ELSE 0 END) AS idle,
SUM(CASE WHEN status = 'INACTIVE' AND taddr IS NOT NULL THEN 1 ELSE 0 END) AS idle_in_transaction,
MAX(CASE WHEN status = 'INACTIVE' AND taddr IS NOT NULL THEN last_call_et END) AS longest_idle_in_transaction_seconds,
MAX(CASE WHEN status = 'ACTIVE' THEN last_call_et END) AS longest_active_query_seconds
FROM v$session
WHERE type = 'USER'
AND sid <> SYS_CONTEXT('USERENV', 'SID')`
),
OracleConnector.fetchRows<{ VALUE: string }>(
connection,
`SELECT value FROM v$parameter WHERE name = 'sessions'`
),
])
);
const conn = sessions[0];
const maxConnections = params.length > 0 ? Number(params[0].VALUE) : null;

result.connections = {
total: Number(conn.TOTAL ?? 0),
active: Number(conn.ACTIVE ?? 0),
idle: Number(conn.IDLE ?? 0),
idleInTransaction: Number(conn.IDLE_IN_TRANSACTION ?? 0),
// Oracle has no equivalent of Postgres's "idle in transaction
// (aborted)" state: a failed statement is rolled back on its own
// and leaves the transaction usable.
maxConnections: maxConnections !== null && maxConnections > 0 ? maxConnections : null,
longestIdleInTransactionSeconds: toNullableNumber(conn.LONGEST_IDLE_IN_TRANSACTION_SECONDS),
longestActiveQuerySeconds: toNullableNumber(conn.LONGEST_ACTIVE_QUERY_SECONDS),
};
} catch {
notes.push(
"Connection pool metrics unavailable: connecting user lacks SELECT on V$SESSION / V$PARAMETER (grant SELECT_CATALOG_ROLE or SELECT ANY DICTIONARY)."
);
}

try {
const stats = await this.query<{ NAME: string; VALUE: number }>(
`SELECT name, value FROM v$sysstat
WHERE name IN ('db block gets', 'consistent gets', 'physical reads')`
);
const byName = Object.fromEntries(stats.map((row) => [row.NAME, Number(row.VALUE)]));
// Logical reads = current-mode gets + consistent-mode gets; physical
// reads are the subset that had to go to disk.
const logicalReads = (byName["db block gets"] ?? 0) + (byName["consistent gets"] ?? 0);
const physicalReads = byName["physical reads"] ?? 0;

result.bufferCache = {
hitRatioPct: computeHitRatioPct(logicalReads, physicalReads),
blocksHit: logicalReads - physicalReads,
blocksRead: physicalReads,
};
} catch {
notes.push(
"Buffer cache metrics unavailable: connecting user lacks SELECT on V$SYSSTAT (grant SELECT_CATALOG_ROLE or SELECT ANY DICTIONARY)."
);
}

if (notes.length > 0) {
result.notes = notes;
}

return result;
}

async getStoredProcedures(schema?: string, routineType?: "procedure" | "function"): Promise<string[]> {
try {
const typeFilter =
Expand Down
Loading