1195 lines
31 KiB
JavaScript
1195 lines
31 KiB
JavaScript
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import process from 'node:process';
|
|
import { spawn } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
import oracledb from 'oracledb';
|
|
import * as z from 'zod/v4';
|
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
const ENV_FILE_PATH = resolveEnvFilePath();
|
|
|
|
loadEnvFile(ENV_FILE_PATH);
|
|
|
|
const FETCH_ARRAY_SIZE = parsePositiveInt(process.env.ORACLE_FETCH_ARRAY_SIZE, 100);
|
|
const DEFAULT_MAX_ROWS = parsePositiveInt(process.env.ORACLE_DEFAULT_MAX_ROWS, 200);
|
|
const HARD_MAX_ROWS = parsePositiveInt(process.env.ORACLE_HARD_MAX_ROWS, 1000);
|
|
const CALL_TIMEOUT_MS = parsePositiveInt(process.env.ORACLE_CALL_TIMEOUT_MS, 15000);
|
|
const POOL_MIN = parseNonNegativeInt(process.env.ORACLE_POOL_MIN, 0);
|
|
const POOL_MAX = parsePositiveInt(process.env.ORACLE_POOL_MAX, 4);
|
|
const POOL_INCREMENT = parsePositiveInt(process.env.ORACLE_POOL_INCREMENT, 1);
|
|
const POOL_TIMEOUT = parsePositiveInt(process.env.ORACLE_POOL_TIMEOUT, 60);
|
|
const QUEUE_TIMEOUT_MS = parsePositiveInt(process.env.ORACLE_QUEUE_TIMEOUT_MS, 5000);
|
|
const STMT_CACHE_SIZE = parsePositiveInt(process.env.ORACLE_STMT_CACHE_SIZE, 30);
|
|
const ORACLE_BACKEND = normalizeBackendMode(process.env.ORACLE_BACKEND || 'auto');
|
|
const ORACLE_SQLCL_TIMEOUT_MS = parsePositiveInt(process.env.ORACLE_SQLCL_TIMEOUT_MS, CALL_TIMEOUT_MS + 5000);
|
|
const ORACLE_SQLCL_PATH = resolveSqlclPath(process.env.ORACLE_SQLCL_PATH);
|
|
|
|
oracledb.outFormat = oracledb.OUT_FORMAT_OBJECT;
|
|
oracledb.fetchAsString = [oracledb.CLOB];
|
|
oracledb.fetchArraySize = FETCH_ARRAY_SIZE;
|
|
oracledb.prefetchRows = FETCH_ARRAY_SIZE;
|
|
|
|
const runtimeConfig = buildRuntimeConfig();
|
|
const poolCache = new Map();
|
|
|
|
function loadEnvFile(filePath) {
|
|
if (!fs.existsSync(filePath)) {
|
|
return;
|
|
}
|
|
|
|
const content = fs.readFileSync(filePath, 'utf8');
|
|
const lines = content.split(/\r?\n/);
|
|
|
|
for (let index = 0; index < lines.length; index += 1) {
|
|
const rawLine = lines[index];
|
|
const line = rawLine.trim();
|
|
|
|
if (!line || line.charAt(0) == '#') {
|
|
continue;
|
|
}
|
|
|
|
const separatorIndex = rawLine.indexOf('=');
|
|
if (separatorIndex <= 0) {
|
|
continue;
|
|
}
|
|
|
|
const key = rawLine.slice(0, separatorIndex).trim();
|
|
let value = rawLine.slice(separatorIndex + 1).trim();
|
|
|
|
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
value = value.slice(1, -1);
|
|
}
|
|
|
|
value = value.replace(/\\n/g, '\n');
|
|
|
|
if (process.env[key] === undefined) {
|
|
process.env[key] = value;
|
|
}
|
|
}
|
|
}
|
|
|
|
function resolveEnvFilePath() {
|
|
const explicitPath = String(process.env.MCP_ORACLE_ENV_FILE || process.env.ORACLE_ENV_FILE || '').trim();
|
|
const candidates = [];
|
|
const visitedDirs = new Set();
|
|
|
|
if (explicitPath) {
|
|
candidates.push(path.resolve(process.cwd(), explicitPath));
|
|
}
|
|
|
|
collectSearchDirectories(process.cwd(), visitedDirs).forEach((searchDir) => {
|
|
candidates.push(path.join(searchDir, '.env'));
|
|
candidates.push(path.join(searchDir, 'mcp-oracle-custom', '.env'));
|
|
});
|
|
|
|
candidates.push(path.join(__dirname, '.env'));
|
|
|
|
for (let index = 0; index < candidates.length; index += 1) {
|
|
const candidate = String(candidates[index] || '').trim();
|
|
if (candidate && fs.existsSync(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
return explicitPath ? path.resolve(process.cwd(), explicitPath) : path.join(__dirname, '.env');
|
|
}
|
|
|
|
function collectSearchDirectories(startDir, visitedDirs) {
|
|
const directories = [];
|
|
let currentDir = path.resolve(startDir || process.cwd());
|
|
|
|
while (currentDir && !visitedDirs.has(currentDir)) {
|
|
visitedDirs.add(currentDir);
|
|
directories.push(currentDir);
|
|
|
|
const parentDir = path.dirname(currentDir);
|
|
if (parentDir == currentDir) {
|
|
break;
|
|
}
|
|
|
|
currentDir = parentDir;
|
|
}
|
|
|
|
return directories;
|
|
}
|
|
|
|
function parsePositiveInt(value, fallbackValue) {
|
|
const parsed = Number.parseInt(String(value || ''), 10);
|
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
return fallbackValue;
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function parseNonNegativeInt(value, fallbackValue) {
|
|
const parsed = Number.parseInt(String(value || ''), 10);
|
|
if (!Number.isFinite(parsed) || parsed < 0) {
|
|
return fallbackValue;
|
|
}
|
|
|
|
return parsed;
|
|
}
|
|
|
|
function normalizeBackendMode(value) {
|
|
const normalized = String(value || 'auto').trim().toLowerCase();
|
|
if (normalized == 'oracledb' || normalized == 'sqlcl') {
|
|
return normalized;
|
|
}
|
|
|
|
return 'auto';
|
|
}
|
|
|
|
function resolveSqlclPath(explicitPath) {
|
|
const candidates = [];
|
|
const normalizedExplicitPath = String(explicitPath || '').trim();
|
|
|
|
if (normalizedExplicitPath) {
|
|
candidates.push(normalizedExplicitPath);
|
|
}
|
|
|
|
collectVsCodeExtensionDirs().forEach((extensionsDir) => {
|
|
if (!fs.existsSync(extensionsDir)) {
|
|
return;
|
|
}
|
|
|
|
const extensionNames = fs.readdirSync(extensionsDir)
|
|
.filter((entry) => /^oracle\.sql-developer-.*-(linux-x64|win32-x64|darwin-(x64|arm64))$/.test(entry))
|
|
.sort()
|
|
.reverse();
|
|
|
|
extensionNames.forEach((entry) => {
|
|
candidates.push(path.join(extensionsDir, entry, 'dbtools', 'sqlcl', 'bin', process.platform == 'win32' ? 'sql.exe' : 'sql'));
|
|
});
|
|
});
|
|
|
|
const defaultCandidates = [
|
|
'/usr/local/bin/sql',
|
|
'/usr/bin/sql',
|
|
path.join(os.homedir(), '.local', 'bin', 'sql')
|
|
];
|
|
|
|
defaultCandidates.forEach((candidate) => {
|
|
candidates.push(candidate);
|
|
});
|
|
|
|
for (let index = 0; index < candidates.length; index += 1) {
|
|
const candidate = String(candidates[index] || '').trim();
|
|
if (candidate && fs.existsSync(candidate)) {
|
|
return candidate;
|
|
}
|
|
}
|
|
|
|
return normalizedExplicitPath;
|
|
}
|
|
|
|
function collectVsCodeExtensionDirs() {
|
|
const homeDir = os.homedir();
|
|
const baseDirs = [];
|
|
const uniqueDirs = new Set();
|
|
|
|
if (process.env.VSCODE_AGENT_FOLDER) {
|
|
baseDirs.push(process.env.VSCODE_AGENT_FOLDER);
|
|
}
|
|
|
|
if (homeDir) {
|
|
baseDirs.push(
|
|
path.join(homeDir, '.vscode-server'),
|
|
path.join(homeDir, '.vscode-server-insiders'),
|
|
path.join(homeDir, '.vscode'),
|
|
path.join(homeDir, '.vscode-insiders')
|
|
);
|
|
}
|
|
|
|
return baseDirs.reduce((directories, baseDir) => {
|
|
const normalizedBaseDir = String(baseDir || '').trim();
|
|
if (!normalizedBaseDir) {
|
|
return directories;
|
|
}
|
|
|
|
const extensionsDir = path.join(normalizedBaseDir, 'extensions');
|
|
if (!uniqueDirs.has(extensionsDir)) {
|
|
uniqueDirs.add(extensionsDir);
|
|
directories.push(extensionsDir);
|
|
}
|
|
|
|
return directories;
|
|
}, []);
|
|
}
|
|
|
|
function isSqlclAvailable() {
|
|
return Boolean(ORACLE_SQLCL_PATH) && fs.existsSync(ORACLE_SQLCL_PATH);
|
|
}
|
|
|
|
function buildRuntimeConfig() {
|
|
const connections = {};
|
|
let defaultConnection = String(process.env.ORACLE_DEFAULT_CONNECTION || '').trim();
|
|
|
|
if (process.env.ORACLE_CONNECTIONS_JSON) {
|
|
let jsonConfig;
|
|
try {
|
|
jsonConfig = JSON.parse(process.env.ORACLE_CONNECTIONS_JSON);
|
|
} catch (error) {
|
|
throw new Error('ORACLE_CONNECTIONS_JSON invalido: ' + String(error));
|
|
}
|
|
|
|
Object.keys(jsonConfig || {}).forEach((connectionName) => {
|
|
const row = jsonConfig[connectionName] || {};
|
|
const normalized = normalizeConnectionConfig(connectionName, row);
|
|
connections[normalized.name] = normalized;
|
|
});
|
|
}
|
|
|
|
if (process.env.ORACLE_USER && process.env.ORACLE_PASSWORD && process.env.ORACLE_CONNECT_STRING) {
|
|
const singleName = String(process.env.ORACLE_CONNECTION_NAME || defaultConnection || 'default').trim();
|
|
const normalized = normalizeConnectionConfig(singleName, {
|
|
user: process.env.ORACLE_USER,
|
|
password: process.env.ORACLE_PASSWORD,
|
|
connectString: process.env.ORACLE_CONNECT_STRING
|
|
});
|
|
|
|
connections[normalized.name] = normalized;
|
|
if (!defaultConnection) {
|
|
defaultConnection = normalized.name;
|
|
}
|
|
}
|
|
|
|
const connectionNames = Object.keys(connections).sort();
|
|
if (!defaultConnection && connectionNames.length > 0) {
|
|
defaultConnection = connectionNames[0];
|
|
}
|
|
|
|
return {
|
|
connections,
|
|
defaultConnection,
|
|
connectionNames
|
|
};
|
|
}
|
|
|
|
function normalizeConnectionConfig(connectionName, config) {
|
|
const normalizedName = String(connectionName || '').trim();
|
|
const user = String(config.user || '').trim();
|
|
const password = String(config.password || '').trim();
|
|
const connectString = String(config.connectString || config.connect_string || '').trim();
|
|
|
|
if (!normalizedName || !user || !password || !connectString) {
|
|
throw new Error('Configuracao de conexao invalida para ' + String(connectionName || '<sem nome>') + '.');
|
|
}
|
|
|
|
return {
|
|
name: normalizedName,
|
|
user,
|
|
password,
|
|
connectString
|
|
};
|
|
}
|
|
|
|
function getConnectionConfig(connectionName) {
|
|
const requested = String(connectionName || runtimeConfig.defaultConnection || '').trim();
|
|
|
|
if (!requested) {
|
|
throw new Error('Nenhuma conexao configurada. Preencha o arquivo .env do MCP Oracle.');
|
|
}
|
|
|
|
const config = runtimeConfig.connections[requested];
|
|
if (!config) {
|
|
throw new Error('Conexao nao encontrada: ' + requested + '. Conexoes disponiveis: ' + runtimeConfig.connectionNames.join(', '));
|
|
}
|
|
|
|
return config;
|
|
}
|
|
|
|
async function getPool(connectionName) {
|
|
const config = getConnectionConfig(connectionName);
|
|
|
|
if (poolCache.has(config.name)) {
|
|
return poolCache.get(config.name);
|
|
}
|
|
|
|
const pool = await oracledb.createPool({
|
|
user: config.user,
|
|
password: config.password,
|
|
connectString: config.connectString,
|
|
poolMin: POOL_MIN,
|
|
poolMax: POOL_MAX,
|
|
poolIncrement: POOL_INCREMENT,
|
|
poolTimeout: POOL_TIMEOUT,
|
|
queueTimeout: QUEUE_TIMEOUT_MS,
|
|
stmtCacheSize: STMT_CACHE_SIZE,
|
|
homogeneous: true
|
|
});
|
|
|
|
poolCache.set(config.name, pool);
|
|
return pool;
|
|
}
|
|
|
|
async function withConnection(connectionName, callback) {
|
|
const config = getConnectionConfig(connectionName);
|
|
const pool = await getPool(config.name);
|
|
let connection;
|
|
|
|
try {
|
|
connection = await pool.getConnection();
|
|
connection.callTimeout = CALL_TIMEOUT_MS;
|
|
return await callback(connection, config);
|
|
} finally {
|
|
if (connection) {
|
|
try {
|
|
await connection.close();
|
|
} catch (error) {
|
|
console.error('Falha ao fechar conexao Oracle:', error);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function stripLeadingComments(sql) {
|
|
let cleaned = String(sql || '');
|
|
let changed = true;
|
|
|
|
while (changed) {
|
|
changed = false;
|
|
cleaned = cleaned.replace(/^\s+/, '');
|
|
|
|
if (cleaned.startsWith('--')) {
|
|
const newLineIndex = cleaned.indexOf('\n');
|
|
cleaned = newLineIndex >= 0 ? cleaned.slice(newLineIndex + 1) : '';
|
|
changed = true;
|
|
continue;
|
|
}
|
|
|
|
if (cleaned.startsWith('/*')) {
|
|
const endIndex = cleaned.indexOf('*/');
|
|
cleaned = endIndex >= 0 ? cleaned.slice(endIndex + 2) : '';
|
|
changed = true;
|
|
}
|
|
}
|
|
|
|
return cleaned.trim();
|
|
}
|
|
|
|
function validateReadOnlySql(sql) {
|
|
const original = String(sql || '').trim();
|
|
if (!original) {
|
|
throw new Error('Informe um SQL para executar.');
|
|
}
|
|
|
|
if (original.indexOf(';') >= 0) {
|
|
throw new Error('Envie apenas uma consulta por vez e sem ponto e virgula.');
|
|
}
|
|
|
|
const cleaned = stripLeadingComments(original);
|
|
if (!/^(select|with)\b/i.test(cleaned)) {
|
|
throw new Error('Este MCP aceita apenas consultas de leitura iniciando com SELECT ou WITH.');
|
|
}
|
|
|
|
const withoutStrings = cleaned.replace(/'([^']|'')*'/g, "''");
|
|
if (/\b(insert|update|delete|merge|alter|drop|truncate|grant|revoke|commit|rollback|begin|declare|execute|call)\b/i.test(withoutStrings)) {
|
|
throw new Error('Foi detectado um comando nao permitido para este MCP de leitura.');
|
|
}
|
|
|
|
return cleaned;
|
|
}
|
|
|
|
function parseBindsJson(bindsJson) {
|
|
if (bindsJson === undefined || bindsJson === null || String(bindsJson).trim() == '') {
|
|
return {};
|
|
}
|
|
|
|
let parsed;
|
|
try {
|
|
parsed = JSON.parse(bindsJson);
|
|
} catch (error) {
|
|
throw new Error('bindsJson invalido: ' + String(error));
|
|
}
|
|
|
|
if (Array.isArray(parsed)) {
|
|
return parsed;
|
|
}
|
|
|
|
if (parsed && typeof parsed == 'object') {
|
|
return parsed;
|
|
}
|
|
|
|
throw new Error('bindsJson deve ser um objeto JSON ou um array JSON.');
|
|
}
|
|
|
|
function serializeValue(value) {
|
|
if (value === null || value === undefined) {
|
|
return null;
|
|
}
|
|
|
|
if (value instanceof Date) {
|
|
return value.toISOString();
|
|
}
|
|
|
|
if (Buffer.isBuffer(value)) {
|
|
return value.toString('base64');
|
|
}
|
|
|
|
if (Array.isArray(value)) {
|
|
return value.map((item) => serializeValue(item));
|
|
}
|
|
|
|
if (typeof value == 'bigint') {
|
|
return String(value);
|
|
}
|
|
|
|
if (typeof value == 'object') {
|
|
const serialized = {};
|
|
Object.keys(value).forEach((key) => {
|
|
serialized[key] = serializeValue(value[key]);
|
|
});
|
|
return serialized;
|
|
}
|
|
|
|
return value;
|
|
}
|
|
|
|
function stripAnsi(text) {
|
|
return String(text || '').replace(/\u001b\[[0-9;?]*[ -/]*[@-~]/g, '');
|
|
}
|
|
|
|
function extractJsonPayload(text) {
|
|
const source = stripAnsi(text);
|
|
let startIndex = -1;
|
|
let openingChar = '';
|
|
|
|
for (let index = 0; index < source.length; index += 1) {
|
|
const current = source.charAt(index);
|
|
if (current == '{' || current == '[') {
|
|
startIndex = index;
|
|
openingChar = current;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (startIndex < 0) {
|
|
throw new Error('Nao foi possivel localizar o payload JSON retornado pelo SQLcl.');
|
|
}
|
|
|
|
const closingChar = openingChar == '{' ? '}' : ']';
|
|
let depth = 0;
|
|
let inString = false;
|
|
let escaped = false;
|
|
|
|
for (let index = startIndex; index < source.length; index += 1) {
|
|
const current = source.charAt(index);
|
|
|
|
if (inString) {
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
|
|
if (current == '\\') {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
|
|
if (current == '"') {
|
|
inString = false;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (current == '"') {
|
|
inString = true;
|
|
continue;
|
|
}
|
|
|
|
if (current == openingChar) {
|
|
depth += 1;
|
|
continue;
|
|
}
|
|
|
|
if (current == closingChar) {
|
|
depth -= 1;
|
|
if (depth == 0) {
|
|
return source.slice(startIndex, index + 1);
|
|
}
|
|
}
|
|
}
|
|
|
|
throw new Error('O payload JSON retornado pelo SQLcl parece incompleto.');
|
|
}
|
|
|
|
function parseJsonStrictOrLenient(payloadText) {
|
|
const payload = String(payloadText || '').trim();
|
|
|
|
try {
|
|
return JSON.parse(payload);
|
|
} catch (strictError) {
|
|
// SQLcl pode retornar JSON quase valido com chaves sem aspas e/ou virgula final.
|
|
const withoutTrailingCommas = payload.replace(/,\s*([}\]])/g, '$1');
|
|
const withQuotedKeys = withoutTrailingCommas.replace(/([{,]\s*)([A-Za-z_][A-Za-z0-9_$]*)(\s*:)/g, '$1"$2"$3');
|
|
const withNormalizedNumbers = normalizeJsonLocaleNumbers(withQuotedKeys);
|
|
|
|
try {
|
|
return JSON.parse(withNormalizedNumbers);
|
|
} catch (lenientError) {
|
|
throw strictError;
|
|
}
|
|
}
|
|
}
|
|
|
|
function isDigitCharacter(value) {
|
|
const code = String(value || '').charCodeAt(0);
|
|
return code >= 48 && code <= 57;
|
|
}
|
|
|
|
function isLooseJsonNumberStart(text, index) {
|
|
const current = text.charAt(index);
|
|
|
|
if (isDigitCharacter(current)) {
|
|
return true;
|
|
}
|
|
|
|
return (current == '-' || current == '+') && isDigitCharacter(text.charAt(index + 1));
|
|
}
|
|
|
|
function readLooseJsonNumberToken(text, startIndex) {
|
|
let token = '';
|
|
let index = startIndex;
|
|
|
|
while (index < text.length) {
|
|
const current = text.charAt(index);
|
|
const next = text.charAt(index + 1);
|
|
const afterNext = text.charAt(index + 2);
|
|
|
|
if (isDigitCharacter(current)) {
|
|
token += current;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if ((current == '+' || current == '-') && token && /[eE]$/.test(token)) {
|
|
token += current;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if ((current == 'e' || current == 'E') && token && (isDigitCharacter(next) || ((next == '+' || next == '-') && isDigitCharacter(afterNext)))) {
|
|
token += current;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
if ((current == '.' || current == ',') && isDigitCharacter(next)) {
|
|
token += current;
|
|
index += 1;
|
|
continue;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
return {
|
|
token,
|
|
endIndex: index
|
|
};
|
|
}
|
|
|
|
function normalizeLooseJsonNumberToken(token) {
|
|
let normalized = String(token || '');
|
|
|
|
if (normalized.indexOf(',') < 0) {
|
|
return normalized;
|
|
}
|
|
|
|
let exponent = '';
|
|
const exponentMatch = normalized.match(/[eE][+-]?\d+$/);
|
|
if (exponentMatch) {
|
|
exponent = exponentMatch[0];
|
|
normalized = normalized.slice(0, -exponent.length);
|
|
}
|
|
|
|
let sign = '';
|
|
if (normalized.charAt(0) == '-' || normalized.charAt(0) == '+') {
|
|
sign = normalized.charAt(0);
|
|
normalized = normalized.slice(1);
|
|
}
|
|
|
|
if (normalized.indexOf('.') >= 0) {
|
|
normalized = normalized.replace(/\./g, '');
|
|
}
|
|
|
|
const lastCommaIndex = normalized.lastIndexOf(',');
|
|
normalized = normalized.slice(0, lastCommaIndex).replace(/,/g, '') + '.' + normalized.slice(lastCommaIndex + 1);
|
|
|
|
return sign + normalized + exponent;
|
|
}
|
|
|
|
function normalizeJsonLocaleNumbers(text) {
|
|
let normalized = '';
|
|
let inString = false;
|
|
let escaped = false;
|
|
|
|
for (let index = 0; index < text.length; index += 1) {
|
|
const current = text.charAt(index);
|
|
|
|
if (inString) {
|
|
normalized += current;
|
|
|
|
if (escaped) {
|
|
escaped = false;
|
|
continue;
|
|
}
|
|
|
|
if (current == '\\') {
|
|
escaped = true;
|
|
continue;
|
|
}
|
|
|
|
if (current == '"') {
|
|
inString = false;
|
|
}
|
|
|
|
continue;
|
|
}
|
|
|
|
if (current == '"') {
|
|
inString = true;
|
|
normalized += current;
|
|
continue;
|
|
}
|
|
|
|
if (isLooseJsonNumberStart(text, index)) {
|
|
const parsed = readLooseJsonNumberToken(text, index);
|
|
normalized += normalizeLooseJsonNumberToken(parsed.token);
|
|
index = parsed.endIndex - 1;
|
|
continue;
|
|
}
|
|
|
|
normalized += current;
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function ensureSqlclAvailable() {
|
|
if (!isSqlclAvailable()) {
|
|
throw new Error('Fallback SQLcl indisponivel. Ajuste ORACLE_SQLCL_PATH para um executavel sql valido.');
|
|
}
|
|
}
|
|
|
|
function escapeSqlclCredential(value) {
|
|
const normalized = String(value || '');
|
|
|
|
if (normalized.indexOf('"') >= 0) {
|
|
throw new Error('O fallback SQLcl nao suporta aspas duplas nas credenciais Oracle.');
|
|
}
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function parseSqlclJsonResult(stdout) {
|
|
const payload = parseJsonStrictOrLenient(extractJsonPayload(stdout));
|
|
const firstResult = Array.isArray(payload.results) && payload.results.length > 0 ? payload.results[0] : {};
|
|
const columns = Array.isArray(firstResult.columns) ? firstResult.columns : [];
|
|
const items = Array.isArray(firstResult.items) ? firstResult.items : [];
|
|
|
|
return {
|
|
columns,
|
|
items
|
|
};
|
|
}
|
|
|
|
function normalizeSqlclRow(row, columns) {
|
|
const normalized = {};
|
|
const source = row && typeof row == 'object' ? row : {};
|
|
|
|
columns.forEach((column) => {
|
|
const columnName = String(column.name || '').trim();
|
|
const lowerCaseName = columnName.toLowerCase();
|
|
|
|
if (Object.prototype.hasOwnProperty.call(source, columnName)) {
|
|
normalized[columnName] = serializeValue(source[columnName]);
|
|
return;
|
|
}
|
|
|
|
if (Object.prototype.hasOwnProperty.call(source, lowerCaseName)) {
|
|
normalized[columnName] = serializeValue(source[lowerCaseName]);
|
|
return;
|
|
}
|
|
|
|
normalized[columnName] = null;
|
|
});
|
|
|
|
return normalized;
|
|
}
|
|
|
|
function shouldUseSqlclFallback(error) {
|
|
const message = String(error && error.message ? error.message : error || '');
|
|
return /NJS-116/i.test(message);
|
|
}
|
|
|
|
function runSqlclScript(connectionName, scriptLines) {
|
|
const config = getConnectionConfig(connectionName);
|
|
|
|
ensureSqlclAvailable();
|
|
|
|
const sqlLines = [
|
|
'WHENEVER OSERROR EXIT 9',
|
|
'WHENEVER SQLERROR EXIT SQL.SQLCODE',
|
|
'connect ' + config.user + '/"' + escapeSqlclCredential(config.password) + '"@' + config.connectString,
|
|
'set pagesize 50000',
|
|
'set feedback off',
|
|
'set verify off',
|
|
'set echo off',
|
|
'set heading off'
|
|
].concat(scriptLines || [], ['exit', '']);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(ORACLE_SQLCL_PATH, ['-s', '/nolog'], {
|
|
cwd: __dirname,
|
|
stdio: ['pipe', 'pipe', 'pipe']
|
|
});
|
|
|
|
let stdout = '';
|
|
let stderr = '';
|
|
let finished = false;
|
|
|
|
const timeoutHandle = setTimeout(() => {
|
|
if (!finished) {
|
|
finished = true;
|
|
child.kill('SIGKILL');
|
|
reject(new Error('Tempo limite excedido ao executar SQLcl (' + ORACLE_SQLCL_TIMEOUT_MS + ' ms).'));
|
|
}
|
|
}, ORACLE_SQLCL_TIMEOUT_MS);
|
|
|
|
child.stdout.setEncoding('utf8');
|
|
child.stderr.setEncoding('utf8');
|
|
|
|
child.stdout.on('data', (chunk) => {
|
|
stdout += chunk;
|
|
});
|
|
|
|
child.stderr.on('data', (chunk) => {
|
|
stderr += chunk;
|
|
});
|
|
|
|
child.on('error', (error) => {
|
|
if (finished) {
|
|
return;
|
|
}
|
|
|
|
finished = true;
|
|
clearTimeout(timeoutHandle);
|
|
reject(error);
|
|
});
|
|
|
|
child.on('close', (code) => {
|
|
if (finished) {
|
|
return;
|
|
}
|
|
|
|
finished = true;
|
|
clearTimeout(timeoutHandle);
|
|
|
|
const stdoutText = stripAnsi(stdout).trim();
|
|
const stderrText = stripAnsi(stderr).trim();
|
|
|
|
if (code !== 0) {
|
|
reject(new Error(stderrText || stdoutText || ('SQLcl finalizou com codigo ' + String(code))));
|
|
return;
|
|
}
|
|
|
|
resolve({
|
|
config,
|
|
stdout: stdoutText,
|
|
stderr: stderrText
|
|
});
|
|
});
|
|
|
|
child.stdin.end(sqlLines.join('\n'));
|
|
});
|
|
}
|
|
|
|
async function runReadOnlyQueryWithOracleDb({ connectionName, sql, bindsJson, maxRows }) {
|
|
const binds = parseBindsJson(bindsJson);
|
|
|
|
return withConnection(connectionName, async (connection, config) => {
|
|
const result = await connection.execute(sql, binds, {
|
|
outFormat: oracledb.OUT_FORMAT_OBJECT,
|
|
resultSet: true,
|
|
fetchArraySize: Math.min(FETCH_ARRAY_SIZE, maxRows),
|
|
prefetchRows: Math.min(FETCH_ARRAY_SIZE, maxRows)
|
|
});
|
|
|
|
const columns = (result.metaData || []).map((column) => ({
|
|
name: column.name,
|
|
dbTypeName: column.dbTypeName || null,
|
|
nullable: column.nullable,
|
|
precision: column.precision,
|
|
scale: column.scale
|
|
}));
|
|
|
|
const rows = [];
|
|
let truncated = false;
|
|
|
|
if (result.resultSet) {
|
|
try {
|
|
while (rows.length < maxRows) {
|
|
const batchSize = Math.min(FETCH_ARRAY_SIZE, maxRows - rows.length);
|
|
const batch = await result.resultSet.getRows(batchSize);
|
|
if (!batch || batch.length == 0) {
|
|
break;
|
|
}
|
|
|
|
rows.push.apply(rows, batch.map((row) => serializeValue(row)));
|
|
}
|
|
|
|
const overflowCheck = await result.resultSet.getRows(1);
|
|
truncated = Array.isArray(overflowCheck) && overflowCheck.length > 0;
|
|
} finally {
|
|
await result.resultSet.close();
|
|
}
|
|
}
|
|
|
|
return {
|
|
backend: 'oracledb',
|
|
connection: config.name,
|
|
connectString: config.connectString,
|
|
rowsReturned: rows.length,
|
|
maxRowsApplied: maxRows,
|
|
truncated,
|
|
columns,
|
|
rows
|
|
};
|
|
});
|
|
}
|
|
|
|
async function runReadOnlyQueryWithSqlcl({ connectionName, sql, bindsJson, maxRows }) {
|
|
if (bindsJson !== undefined && bindsJson !== null && String(bindsJson).trim() != '') {
|
|
throw new Error('O fallback SQLcl nao suporta bindsJson. Ajuste ORACLE_BACKEND=oracledb ou remova os binds.');
|
|
}
|
|
|
|
const limitedSql = 'SELECT * FROM (\n' + sql + '\n) WHERE ROWNUM <= ' + String(maxRows + 1);
|
|
const sqlclResult = await runSqlclScript(connectionName, [
|
|
'set sqlformat json',
|
|
limitedSql + ';'
|
|
]);
|
|
const parsed = parseSqlclJsonResult(sqlclResult.stdout);
|
|
const normalizedColumns = parsed.columns.map((column) => ({
|
|
name: column.name,
|
|
dbTypeName: column.type || null,
|
|
nullable: null,
|
|
precision: null,
|
|
scale: null
|
|
}));
|
|
const normalizedRows = parsed.items.map((row) => normalizeSqlclRow(row, normalizedColumns));
|
|
const truncated = normalizedRows.length > maxRows;
|
|
|
|
return {
|
|
backend: 'sqlcl',
|
|
connection: sqlclResult.config.name,
|
|
connectString: sqlclResult.config.connectString,
|
|
rowsReturned: Math.min(normalizedRows.length, maxRows),
|
|
maxRowsApplied: maxRows,
|
|
truncated,
|
|
columns: normalizedColumns,
|
|
rows: normalizedRows.slice(0, maxRows)
|
|
};
|
|
}
|
|
|
|
async function testConnectionWithOracleDb(connectionName) {
|
|
return withConnection(connectionName, async (connection, config) => {
|
|
const result = await connection.execute(
|
|
"SELECT USER AS USUARIO, SYS_CONTEXT('USERENV', 'DB_NAME') AS BANCO, SYS_CONTEXT('USERENV', 'INSTANCE_NAME') AS INSTANCIA, TO_CHAR(SYSTIMESTAMP, 'YYYY-MM-DD HH24:MI:SS') AS DATA_SERVIDOR FROM DUAL",
|
|
{},
|
|
{
|
|
outFormat: oracledb.OUT_FORMAT_OBJECT
|
|
}
|
|
);
|
|
|
|
return {
|
|
backend: 'oracledb',
|
|
connection: config.name,
|
|
connectString: config.connectString,
|
|
data: serializeValue((result.rows || [])[0] || {})
|
|
};
|
|
});
|
|
}
|
|
|
|
async function testConnectionWithSqlcl(connectionName) {
|
|
const sqlclResult = await runSqlclScript(connectionName, [
|
|
'set sqlformat json',
|
|
"SELECT USER AS USUARIO, SYS_CONTEXT('USERENV', 'DB_NAME') AS BANCO, SYS_CONTEXT('USERENV', 'INSTANCE_NAME') AS INSTANCIA, TO_CHAR(SYSTIMESTAMP, 'YYYY-MM-DD HH24:MI:SS') AS DATA_SERVIDOR FROM DUAL;"
|
|
]);
|
|
const parsed = parseSqlclJsonResult(sqlclResult.stdout);
|
|
const normalizedColumns = parsed.columns.map((column) => ({
|
|
name: column.name,
|
|
dbTypeName: column.type || null,
|
|
nullable: null,
|
|
precision: null,
|
|
scale: null
|
|
}));
|
|
const firstRow = normalizeSqlclRow(parsed.items[0] || {}, normalizedColumns);
|
|
|
|
return {
|
|
backend: 'sqlcl',
|
|
connection: sqlclResult.config.name,
|
|
connectString: sqlclResult.config.connectString,
|
|
data: firstRow
|
|
};
|
|
}
|
|
|
|
async function runReadOnlyQuery({ connectionName, sql, bindsJson, maxRows }) {
|
|
const validatedSql = validateReadOnlySql(sql);
|
|
const effectiveMaxRows = Math.min(parsePositiveInt(maxRows, DEFAULT_MAX_ROWS), HARD_MAX_ROWS);
|
|
|
|
if (ORACLE_BACKEND == 'sqlcl') {
|
|
return runReadOnlyQueryWithSqlcl({
|
|
connectionName,
|
|
sql: validatedSql,
|
|
bindsJson,
|
|
maxRows: effectiveMaxRows
|
|
});
|
|
}
|
|
|
|
if (ORACLE_BACKEND == 'oracledb') {
|
|
return runReadOnlyQueryWithOracleDb({
|
|
connectionName,
|
|
sql: validatedSql,
|
|
bindsJson,
|
|
maxRows: effectiveMaxRows
|
|
});
|
|
}
|
|
|
|
try {
|
|
return await runReadOnlyQueryWithOracleDb({
|
|
connectionName,
|
|
sql: validatedSql,
|
|
bindsJson,
|
|
maxRows: effectiveMaxRows
|
|
});
|
|
} catch (error) {
|
|
if (!shouldUseSqlclFallback(error)) {
|
|
throw error;
|
|
}
|
|
|
|
return runReadOnlyQueryWithSqlcl({
|
|
connectionName,
|
|
sql: validatedSql,
|
|
bindsJson,
|
|
maxRows: effectiveMaxRows
|
|
});
|
|
}
|
|
}
|
|
|
|
async function testConnection(connectionName) {
|
|
if (ORACLE_BACKEND == 'sqlcl') {
|
|
return testConnectionWithSqlcl(connectionName);
|
|
}
|
|
|
|
if (ORACLE_BACKEND == 'oracledb') {
|
|
return testConnectionWithOracleDb(connectionName);
|
|
}
|
|
|
|
try {
|
|
return await testConnectionWithOracleDb(connectionName);
|
|
} catch (error) {
|
|
if (!shouldUseSqlclFallback(error)) {
|
|
throw error;
|
|
}
|
|
|
|
return testConnectionWithSqlcl(connectionName);
|
|
}
|
|
}
|
|
|
|
async function resetPools() {
|
|
const names = Array.from(poolCache.keys());
|
|
|
|
for (let index = 0; index < names.length; index += 1) {
|
|
const name = names[index];
|
|
const pool = poolCache.get(name);
|
|
if (pool) {
|
|
await pool.close(0);
|
|
}
|
|
poolCache.delete(name);
|
|
}
|
|
|
|
return {
|
|
closedPools: names.length,
|
|
connections: names
|
|
};
|
|
}
|
|
|
|
function formatTextResult(payload) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: JSON.stringify(payload, null, 2)
|
|
}
|
|
]
|
|
};
|
|
}
|
|
|
|
function extractOracleErrorSummary(message) {
|
|
const normalized = String(message || '').replace(/\r\n?/g, '\n').trim();
|
|
const oracleMatch = normalized.match(/(?:Erro de SQL:\s*)?(ORA-\d{5}:[^\n]+)/i);
|
|
|
|
if (!oracleMatch) {
|
|
return '';
|
|
}
|
|
|
|
const summary = [oracleMatch[1].trim()];
|
|
const commandLineMatch = normalized.match(/Erro na Linha de Comandos\s*:\s*([^\n]+)/i);
|
|
|
|
if (commandLineMatch) {
|
|
summary.push('Linha/coluna: ' + commandLineMatch[1].trim());
|
|
}
|
|
|
|
return summary.join('\n');
|
|
}
|
|
|
|
function normalizeErrorMessage(error) {
|
|
const message = String(error && error.message ? error.message : error || '').replace(/\r\n?/g, '\n').trim();
|
|
|
|
if (!message) {
|
|
return 'Erro nao identificado.';
|
|
}
|
|
|
|
const oracleSummary = extractOracleErrorSummary(message);
|
|
if (oracleSummary) {
|
|
return oracleSummary;
|
|
}
|
|
|
|
return message;
|
|
}
|
|
|
|
function formatErrorResult(error) {
|
|
return {
|
|
content: [
|
|
{
|
|
type: 'text',
|
|
text: normalizeErrorMessage(error)
|
|
}
|
|
],
|
|
isError: true
|
|
};
|
|
}
|
|
|
|
const server = new McpServer({
|
|
name: 'oracle-davinti',
|
|
version: '1.0.0'
|
|
});
|
|
|
|
server.registerTool(
|
|
'list_connections',
|
|
{
|
|
title: 'List Oracle Connections',
|
|
description: 'Lista as conexoes Oracle configuradas no arquivo .env do MCP.'
|
|
},
|
|
async () => {
|
|
try {
|
|
return formatTextResult({
|
|
defaultConnection: runtimeConfig.defaultConnection || null,
|
|
connections: runtimeConfig.connectionNames,
|
|
configured: runtimeConfig.connectionNames.length > 0,
|
|
backendMode: ORACLE_BACKEND,
|
|
sqlclAvailable: isSqlclAvailable(),
|
|
sqlclPath: isSqlclAvailable() ? ORACLE_SQLCL_PATH : null,
|
|
hint: runtimeConfig.connectionNames.length > 0 ? null : 'Preencha mcp-oracle-custom/.env antes de usar.'
|
|
});
|
|
} catch (error) {
|
|
return formatErrorResult(error);
|
|
}
|
|
}
|
|
);
|
|
|
|
server.registerTool(
|
|
'test_connection',
|
|
{
|
|
title: 'Test Oracle Connection',
|
|
description: 'Testa a conexao com o banco e retorna usuario, banco e horario do servidor.',
|
|
inputSchema: {
|
|
connectionName: z.string().optional().describe('Nome da conexao configurada no .env. Opcional se houver uma conexao padrao.')
|
|
}
|
|
},
|
|
async ({ connectionName }) => {
|
|
try {
|
|
return formatTextResult(await testConnection(connectionName));
|
|
} catch (error) {
|
|
return formatErrorResult(error);
|
|
}
|
|
}
|
|
);
|
|
|
|
server.registerTool(
|
|
'run_query',
|
|
{
|
|
title: 'Run Read-only Oracle Query',
|
|
description: 'Executa consultas Oracle de leitura com timeout, pool e limite de linhas.',
|
|
inputSchema: {
|
|
sql: z.string().min(1).describe('Consulta Oracle iniciando com SELECT ou WITH.'),
|
|
connectionName: z.string().optional().describe('Nome da conexao configurada no .env. Opcional se houver uma conexao padrao.'),
|
|
bindsJson: z.string().optional().describe('JSON com binds nomeados ou posicionais. Exemplo: {"ParLoja":1219472}'),
|
|
maxRows: z.number().int().positive().optional().describe('Quantidade maxima de linhas retornadas. Respeita o limite rigido configurado no servidor.')
|
|
}
|
|
},
|
|
async ({ sql, connectionName, bindsJson, maxRows }) => {
|
|
try {
|
|
return formatTextResult(await runReadOnlyQuery({
|
|
sql,
|
|
connectionName,
|
|
bindsJson,
|
|
maxRows
|
|
}));
|
|
} catch (error) {
|
|
return formatErrorResult(error);
|
|
}
|
|
}
|
|
);
|
|
|
|
server.registerTool(
|
|
'reset_pools',
|
|
{
|
|
title: 'Reset Oracle Pools',
|
|
description: 'Fecha todos os pools Oracle abertos por este servidor MCP.'
|
|
},
|
|
async () => {
|
|
try {
|
|
return formatTextResult(await resetPools());
|
|
} catch (error) {
|
|
return formatErrorResult(error);
|
|
}
|
|
}
|
|
);
|
|
|
|
async function shutdown() {
|
|
try {
|
|
await resetPools();
|
|
} catch (error) {
|
|
console.error('Falha ao fechar pools Oracle:', error);
|
|
}
|
|
}
|
|
|
|
process.on('SIGINT', async () => {
|
|
await shutdown();
|
|
process.exit(0);
|
|
});
|
|
|
|
process.on('SIGTERM', async () => {
|
|
await shutdown();
|
|
process.exit(0);
|
|
});
|
|
|
|
const transport = new StdioServerTransport();
|
|
|
|
server.connect(transport).catch(async (error) => {
|
|
console.error('Falha ao iniciar o MCP Oracle:', error);
|
|
await shutdown();
|
|
process.exit(1);
|
|
});
|