feat(mcp): remove legacy TypeScript runtime

This commit is contained in:
t8y2 2026-07-19 00:47:56 +08:00
parent a357ac7e93
commit 77c1203e6f
56 changed files with 68 additions and 9267 deletions

View File

@ -74,8 +74,6 @@ jobs:
const writeJson = (path, data) => fs.writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`);
for (const path of [
"packages/mongo-shell/package.json",
"packages/node-core/package.json",
"packages/cli/package.json",
"packages/cli-darwin-arm64/package.json",
"packages/cli-darwin-x64/package.json",
@ -175,7 +173,7 @@ jobs:
- name: Commit package release version
run: |
VERSION="${{ steps.version.outputs.version }}"
git add Cargo.lock pnpm-lock.yaml crates/dbx-mcp/Cargo.toml crates/dbx-cli/Cargo.toml packages/mongo-shell/package.json packages/node-core/package.json packages/cli/package.json packages/cli-*/package.json packages/mcp-server/package.json packages/mcp-server/server.json packages/mcp-*/package.json
git add Cargo.lock pnpm-lock.yaml crates/dbx-mcp/Cargo.toml crates/dbx-cli/Cargo.toml packages/cli/package.json packages/cli-*/package.json packages/mcp-server/package.json packages/mcp-server/server.json packages/mcp-*/package.json
if git diff --cached --quiet; then
echo "Package versions already committed for ${VERSION}."
else
@ -208,105 +206,6 @@ jobs:
git push origin "refs/tags/${TAG}:refs/tags/${TAG}"
fi
publish-mongo-shell:
name: Publish mongo-shell
runs-on: ubuntu-latest
needs: prepare
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.tag }}
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22.13.0
registry-url: https://registry.npmjs.org
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Publish Mongo shell parser
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
PACKAGE_PATH="packages/mongo-shell/package.json"
set_package_version() {
PACKAGE_PATH="${PACKAGE_PATH}" PACKAGE_VERSION="$1" node <<'NODE'
const fs = require("fs");
const path = process.env.PACKAGE_PATH;
const pkg = JSON.parse(fs.readFileSync(path, "utf8"));
pkg.version = process.env.PACKAGE_VERSION;
fs.writeFileSync(path, `${JSON.stringify(pkg, null, 2)}\n`);
NODE
}
# node-core@0.4.31 already references ^0.1.0, so bootstrap that version once to repair existing installs.
if ! npm view "@dbx-app/mongo-shell@0.1.0" version >/dev/null 2>&1; then
set_package_version "0.1.0"
pnpm --filter @dbx-app/mongo-shell publish --access public --provenance --no-git-checks
fi
set_package_version "${VERSION}"
if npm view "@dbx-app/mongo-shell@${VERSION}" version >/dev/null 2>&1; then
echo "@dbx-app/mongo-shell@${VERSION} already exists on npm; skipping."
exit 0
fi
pnpm --filter @dbx-app/mongo-shell publish --access public --provenance --no-git-checks
publish-node-core:
name: Publish node-core
runs-on: ubuntu-latest
# node-core keeps mongo-shell as a runtime dependency, so npm must receive it first.
needs: [prepare, publish-mongo-shell]
outputs:
published-or-existing: ${{ steps.publish.outputs.published_or_existing }}
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.tag }}
- name: Setup pnpm
uses: pnpm/action-setup@v6
- name: Setup Node.js
uses: actions/setup-node@v6
with:
node-version: 22.13.0
registry-url: https://registry.npmjs.org
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- name: Install native build dependencies
run: |
sudo apt-get update
sudo apt-get install -y libsecret-1-dev
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Publish Node core
id: publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
if npm view "@dbx-app/node-core@${VERSION}" version >/dev/null 2>&1; then
echo "@dbx-app/node-core@${VERSION} already exists on npm; skipping."
echo "published_or_existing=true" >> "$GITHUB_OUTPUT"
exit 0
fi
pnpm --filter @dbx-app/node-core publish --access public --provenance --no-git-checks
echo "published_or_existing=true" >> "$GITHUB_OUTPUT"
publish-cli-platforms:
name: Publish ${{ matrix.package-name }}
needs: prepare

View File

@ -64,7 +64,7 @@ For a real local Java agent test, build the target `shadowJar`, back up and repl
| `crates/dbx-web/` | Docker / Web HTTP backend |
| `packages/cli/` | `@dbx-app/cli` |
| `packages/mcp-server/` | `@dbx-app/mcp-server` |
| `packages/node-core/` | Shared Node.js bridge and direct-query logic |
| `packages/mongo-shell/` | Private MongoDB editor parsing helpers |
| `docs/` | Official documentation site |
| `examples/` | Sample configs and automation scripts |
| `agents/` | JDBC agent driver projects |

View File

@ -64,7 +64,7 @@ cd agents
| `crates/dbx-web/` | Docker / Web HTTP 后端 |
| `packages/cli/` | `@dbx-app/cli` |
| `packages/mcp-server/` | `@dbx-app/mcp-server` |
| `packages/node-core/` | Node.js bridge 与直连查询逻辑 |
| `packages/mongo-shell/` | 桌面端内部 MongoDB 编辑器解析工具 |
| `docs/` | 官方文档站 |
| `examples/` | 配置与自动化示例 |
| `agents/` | JDBC Agent 驱动工程 |

View File

@ -10,9 +10,9 @@
"dev:web": "vite --config apps/desktop/vite.config.ts --port 5173 --mode web",
"dev:backend": "node scripts/dev-backend.mjs",
"dev:full:win": ".\\scripts\\dev-full.bat",
"build:packages": "pnpm --filter @dbx-app/mongo-shell build && pnpm --filter @dbx-app/node-core build && pnpm --filter @dbx-app/cli build && pnpm --filter @dbx-app/mcp-server build",
"test:packages": "pnpm --filter @dbx-app/node-core test && pnpm --filter @dbx-app/cli test && pnpm --filter @dbx-app/mcp-server test",
"pack:packages": "rm -rf /tmp/dbx-pack-check && mkdir -p /tmp/dbx-pack-check && pnpm --filter @dbx-app/mongo-shell pack --pack-destination /tmp/dbx-pack-check && pnpm --filter @dbx-app/node-core pack --pack-destination /tmp/dbx-pack-check && pnpm --filter @dbx-app/cli pack --pack-destination /tmp/dbx-pack-check && pnpm --filter @dbx-app/mcp-server pack --pack-destination /tmp/dbx-pack-check",
"build:packages": "pnpm --filter @dbx-app/cli build && pnpm --filter @dbx-app/mcp-server build",
"test:packages": "pnpm --filter @dbx-app/cli test && pnpm --filter @dbx-app/mcp-server test",
"pack:packages": "rm -rf /tmp/dbx-pack-check && mkdir -p /tmp/dbx-pack-check && pnpm --filter @dbx-app/cli pack --pack-destination /tmp/dbx-pack-check && pnpm --filter @dbx-app/mcp-server pack --pack-destination /tmp/dbx-pack-check",
"verify:package-install": "node scripts/verify-package-install.mjs /tmp/dbx-pack-check",
"test:cli:migration": "node scripts/verify-cli-migration.mjs",
"benchmark:cli": "node scripts/benchmark-cli.mjs",

View File

@ -29,9 +29,8 @@
"type": "module",
"scripts": {
"start": "cargo run -p dbx-mcp --",
"test": "cargo build -p dbx-mcp && pnpm build:legacy && vitest run --config vitest.config.ts",
"build": "pnpm build:legacy",
"build:legacy": "pnpm --filter @dbx-app/node-core build && tsc",
"test": "cargo build -p dbx-mcp && vitest run --config vitest.config.ts",
"build": "node --check bin/dbx-mcp-server.js",
"prepublishOnly": "node bin/dbx-mcp-server.js --verify-platform"
},
"optionalDependencies": {
@ -43,13 +42,8 @@
"@dbx-app/mcp-win32-x64": "0.4.36"
},
"devDependencies": {
"@dbx-app/node-core": "workspace:^",
"@modelcontextprotocol/sdk": "^1.12.1",
"@types/node": "^22.15.21",
"tsx": "^4.19.4",
"typescript": "^5.8.3",
"vitest": "^4.1.8",
"zod": "^3.25.20"
"vitest": "^4.1.8"
},
"engines": {
"node": ">=18.18.0"

View File

@ -1,541 +0,0 @@
#!/usr/bin/env node
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { createRequire } from "node:module";
import { z } from "zod";
import {
buildSchemaContext,
createBackend,
evaluateRedisCommandSafety,
evaluateMongoAggregateSafety,
evaluateSqlSafety,
formatCell,
formatSchemaContext,
isMainModule,
mdTable,
notifyReload,
parseMongoAggregateCommand,
assessProductionSql,
isLikelyMongoMutation,
isProductionDatabase,
postBridge,
logSqlDiagnostic,
sqlSafetyFromEnv,
splitSqlStatements,
supportsHashLineComments,
type Backend,
type ConnectionConfig,
type QueryResult,
type RedisCommandResult,
} from "@dbx-app/node-core";
const require = createRequire(import.meta.url);
const packageJson = require("../package.json") as { version?: string };
export const DBX_MCP_PACKAGE_VERSION = packageJson.version ?? "0.0.0";
function text(s: string) {
return { content: [{ type: "text" as const, text: s }] };
}
function toolError(code: string, message: string) {
return { ...text(`${code}: ${message}`), isError: true };
}
function withDatabase(config: ConnectionConfig, database?: string): ConnectionConfig {
return database === undefined ? config : { ...config, database };
}
function metadataScope(config: ConnectionConfig, database?: string, schema?: string): { config: ConnectionConfig; schema?: string } {
if (config.db_type !== "dameng") {
return { config: withDatabase(config, database), schema };
}
// Dameng exposes tables under user-owned schemas rather than separate
// databases. Accept the legacy database argument as a schema, and default to
// the login user when neither argument is provided.
const resolvedSchema = schema?.trim() || database?.trim() || config.username?.trim() || undefined;
return { config, schema: resolvedSchema };
}
function connectionIdentity(config: ConnectionConfig): string {
return `${config.name} (${config.id}) [${config.db_type} @ ${config.host}:${config.port}]`;
}
function labeledText(config: ConnectionConfig, body: string): ReturnType<typeof text> {
return text(`[${connectionIdentity(config)}]\n${body}`);
}
function formatQueryToolResult(result: QueryResult, title?: string) {
const prefix = title ? `${title}\n` : "";
if (result.columns.length === 0) return text(`${prefix}Query executed. ${result.row_count} row(s) affected.`);
const rows = result.rows.map((r) => result.columns.map((c) => formatCell(r[c])));
return text(`${prefix}${mdTable(result.columns, rows)}\n\n${result.row_count} row(s)`);
}
function redisDbFromValue(value?: string): number | undefined {
const trimmed = value?.trim();
if (!trimmed) return undefined;
const db = Number(trimmed);
return Number.isInteger(db) && db >= 0 ? db : undefined;
}
function defaultRedisDb(config: ConnectionConfig, scope: McpScope, db?: number): number {
return db ?? redisDbFromValue(scope.database) ?? redisDbFromValue(config.database) ?? 0;
}
function formatRedisCommandValue(value: unknown): string {
if (typeof value === "string") return value;
return JSON.stringify(value, null, 2) ?? String(value);
}
function formatRedisCommandToolResult(result: RedisCommandResult) {
return text(`Command: ${result.command}\nSafety: ${result.safety}\n\n${formatRedisCommandValue(result.value)}`);
}
export const DBX_CONNECTION_TYPE_DESCRIPTION =
"Database type: postgres, mysql, sqlite, rqlite, cloudflare-d1, redis, duckdb, clickhouse, sqlserver, mongodb, oracle, elasticsearch, etcd, doris, starrocks, manticoresearch, milvus, qdrant, weaviate, chromadb, redshift, dameng, kingbase, highgo, vastbase, goldendb, databend, gaussdb, kwdb, yashandb, databricks, saphana, teradata, vertica, firebird, exasol, opengauss, oceanbase-oracle, questdb, gbase, h2, snowflake, trino, prestosql, hive, spark, db2, informix, influxdb, iris, neo4j, cassandra, bigquery, kylin, sundb, oscar, tdengine, iotdb, xugu, zookeeper, jdbc, access, mq";
const FILE_CAPABLE_CONNECTION_TYPES = new Set(["sqlite", "duckdb", "access", "h2"]);
interface McpScope {
connectionId?: string;
connectionName?: string;
database?: string;
}
function scopedValue(value: string | undefined): string | undefined {
const trimmed = value?.trim();
return trimmed ? trimmed : undefined;
}
function mcpScopeFromEnv(): McpScope {
return {
connectionId: scopedValue(process.env.DBX_MCP_SCOPE_CONNECTION_ID),
connectionName: scopedValue(process.env.DBX_MCP_SCOPE_CONNECTION_NAME),
database: scopedValue(process.env.DBX_MCP_SCOPE_DATABASE),
};
}
function scopeEnabled(scope: McpScope): boolean {
return !!(scope.connectionId || scope.connectionName);
}
function connectionMatchesScope(config: ConnectionConfig, scope: McpScope): boolean {
return (!!scope.connectionId && config.id === scope.connectionId) || (!!scope.connectionName && config.name === scope.connectionName);
}
async function loadScopedConnections(backend: Backend, scope: McpScope): Promise<ConnectionConfig[]> {
const connections = await backend.loadConnections();
if (!scopeEnabled(scope)) return connections;
return connections.filter((config) => connectionMatchesScope(config, scope));
}
async function resolveConnection(backend: Backend, scope: McpScope, requestedId?: string, requestedName?: string): Promise<{ config?: ConnectionConfig; error?: ReturnType<typeof toolError> }> {
// connection_id takes priority over connection_name when both are provided.
if (requestedId?.trim()) {
const connections = await backend.loadConnections();
const config = connections.find((c) => c.id === requestedId.trim());
if (!config) return { error: toolError("CONNECTION_NOT_FOUND", `Connection with id "${requestedId}" not found.`) };
// In scoped mode, verify the resolved connection is within the scope.
if (scopeEnabled(scope) && !connectionMatchesScope(config, scope)) {
return { error: toolError("CONNECTION_OUT_OF_SCOPE", `Connection "${requestedId}" is outside this DBX AI session scope.`) };
}
return { config };
}
if (!scopeEnabled(scope)) {
if (!requestedName?.trim()) return { error: toolError("CONNECTION_NOT_FOUND", "Connection name is required.") };
const connections = await backend.loadConnections();
const matching = connections.filter((c) => c.name.toLowerCase() === requestedName.trim().toLowerCase());
if (matching.length === 0) return { error: toolError("CONNECTION_NOT_FOUND", `Connection "${requestedName}" not found.`) };
if (matching.length > 1) {
const lines = matching.map((c) => `- ${c.id}: ${c.db_type} @ ${c.host}:${c.port}`);
return {
error: toolError("AMBIGUOUS_CONNECTION", `Multiple connections found with name "${requestedName}". Please specify connection_id:\n${lines.join("\n")}`),
};
}
return { config: matching[0] };
}
const [scopedConfig] = await loadScopedConnections(backend, scope);
if (!scopedConfig) return { error: toolError("CONNECTION_NOT_FOUND", "Scoped DBX connection was not found.") };
if (requestedName?.trim() && requestedName !== scopedConfig.name && requestedName !== scopedConfig.id) {
return { error: toolError("CONNECTION_OUT_OF_SCOPE", `Connection "${requestedName}" is outside this DBX AI session scope.`) };
}
return { config: scopedConfig };
}
export function createDbxMcpServer(backend: Backend, options: { isWebMode?: boolean } = {}): McpServer {
const isWebMode = options.isWebMode ?? !!process.env.DBX_WEB_URL;
const scope = mcpScopeFromEnv();
const scoped = scopeEnabled(scope);
const server = new McpServer({
name: "dbx",
version: DBX_MCP_PACKAGE_VERSION,
});
server.tool("dbx_list_connections", "List all database connections configured in DBX", {}, async () => {
const connections = await loadScopedConnections(backend, scope);
if (connections.length === 0) return text("No connections configured in DBX.");
const rows = connections.map((c) => [c.id, c.name, c.db_type, c.host, String(c.port), c.database || ""]);
return text(mdTable(["ID", "Name", "Type", "Host", "Port", "Database"], rows));
});
server.tool(
"dbx_list_tables",
"List tables and views for a database connection",
{
connection_id: z.string().optional().describe("Unique ID of the DBX connection (use this to disambiguate when multiple connections share the same name)"),
connection_name: z.string().optional().describe("Name of the DBX connection"),
database: z.string().optional().describe("Database name; for Dameng this is also accepted as a schema alias"),
schema: z.string().optional().describe("Schema name (default: public for PostgreSQL, login user for Dameng)"),
},
async ({ connection_id, connection_name, database, schema }) => {
const { config, error } = await resolveConnection(backend, scope, connection_id, connection_name);
if (error) return error;
const resolvedConfig = config!;
const scopeValue = metadataScope(resolvedConfig, database ?? scope.database, schema);
const tables = await backend.listTables(scopeValue.config, scopeValue.schema);
if (tables.length === 0) return text("No tables found.");
const rows = tables.map((t) => [t.name, t.type]);
return labeledText(resolvedConfig, mdTable(["Table", "Type"], rows));
},
);
server.tool(
"dbx_describe_table",
"Get column definitions for a table",
{
connection_id: z.string().optional().describe("Unique ID of the DBX connection (use this to disambiguate when multiple connections share the same name)"),
connection_name: z.string().optional().describe("Name of the DBX connection"),
table: z.string().describe("Table name"),
database: z.string().optional().describe("Database name; for Dameng this is also accepted as a schema alias"),
schema: z.string().optional().describe("Schema name (default: public for PostgreSQL, login user for Dameng)"),
},
async ({ connection_id, connection_name, table, database, schema }) => {
const { config, error } = await resolveConnection(backend, scope, connection_id, connection_name);
if (error) return error;
const resolvedConfig = config!;
const scopeValue = metadataScope(resolvedConfig, database ?? scope.database, schema);
const columns = await backend.describeTable(scopeValue.config, table, scopeValue.schema);
if (columns.length === 0) return text("No columns found.");
const rows = columns.map((c) => [c.is_primary_key ? `${c.name} (PK)` : c.name, c.data_type, c.is_nullable ? "YES" : "NO", c.column_default ?? "", c.comment ?? ""]);
return labeledText(resolvedConfig, mdTable(["Column", "Type", "Nullable", "Default", "Comment"], rows));
},
);
server.tool(
"dbx_execute_query",
"Execute a SQL query on a database connection (max 100 rows returned)",
{
connection_id: z.string().optional().describe("Unique ID of the DBX connection (use this to disambiguate when multiple connections share the same name)"),
connection_name: z.string().optional().describe("Name of the DBX connection"),
database: z.string().optional().describe("Database name"),
sql: z.string().describe("SQL query to execute"),
},
async ({ connection_id, connection_name, database, sql }) => {
logSqlDiagnostic("dbx_execute_query", sql, { connection_id, connection_name, database });
const { config, error } = await resolveConnection(backend, scope, connection_id, connection_name);
if (error) return error;
const scopedConfig = config!;
if (scopedConfig.db_type === "redis") {
return toolError("REDIS_COMMAND_REQUIRED", "Redis connections do not accept SQL through dbx_execute_query. Use dbx_execute_redis_command with a Redis command such as GET key or INFO.");
}
if (scopedConfig.db_type !== "mongodb") {
const hashLineComments = supportsHashLineComments(scopedConfig.db_type);
const safety = evaluateSqlSafety(sql, { ...sqlSafetyFromEnv(), allowMultipleStatements: true, hashLineComments });
if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "SQL blocked.");
const production = assessProductionSql(sql, scopedConfig, database ?? scope.database ?? scopedConfig.database);
if (production.active && production.isMutation) {
return toolError("PRODUCTION_WRITE_BLOCKED", "MCP cannot execute writes against a production database. Return the SQL for a user to review and run in DBX.");
}
} else if (isProductionDatabase(scopedConfig, database ?? scope.database ?? scopedConfig.database) && isLikelyMongoMutation(sql)) {
return toolError("PRODUCTION_WRITE_BLOCKED", "MCP cannot execute writes against a production database. Return the command for a user to review and run in DBX.");
}
// MongoDB shell commands don't fit the SQL safety evaluator; the backend
// (node-core executeQuery) applies command-aware read/write gating.
try {
const statements = scopedConfig.db_type === "mongodb" ? [sql] : splitSqlStatements(sql, { hashLineComments: supportsHashLineComments(scopedConfig.db_type) });
const results = [];
for (const statement of statements) {
results.push(await backend.executeQuery(withDatabase(scopedConfig, database ?? scope.database), statement));
}
if (results.length === 1) return labeledText(scopedConfig, formatQueryToolResult(results[0]).content[0].text);
return labeledText(scopedConfig, results.map((result, index) => formatQueryToolResult(result, `Statement ${index + 1}`).content[0].text).join("\n\n"));
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
return toolError("QUERY_ERROR", msg);
}
},
);
server.tool(
"dbx_execute_redis_command",
"Execute a Redis command on a Redis connection",
{
connection_id: z.string().optional().describe("Unique ID of the DBX connection (use this to disambiguate when multiple connections share the same name)"),
connection_name: z.string().optional().describe("Name of the DBX Redis connection"),
db: z.number().int().min(0).optional().describe("Redis logical database number (default: scoped/default database or 0)"),
command: z.string().describe("Redis command to execute, for example: GET mykey, INFO, or DBSIZE"),
},
async ({ connection_id, connection_name, db, command }) => {
const { config, error } = await resolveConnection(backend, scope, connection_id, connection_name);
if (error) return error;
const scopedConfig = config!;
if (scopedConfig.db_type !== "redis") {
return toolError("INVALID_CONNECTION_TYPE", `Connection "${scopedConfig.name}" is ${scopedConfig.db_type}, not Redis.`);
}
if (!backend.executeRedisCommand) {
return toolError("UNSUPPORTED_BACKEND", "This DBX backend does not support Redis command execution.");
}
const safety = evaluateRedisCommandSafety(command, sqlSafetyFromEnv());
if (!safety.allowed) return toolError("REDIS_COMMAND_BLOCKED", safety.reason ?? "Redis command blocked.");
if (isProductionDatabase(scopedConfig, String(defaultRedisDb(scopedConfig, scope, db))) && safety.safety !== "allowed") {
return toolError("PRODUCTION_WRITE_BLOCKED", "MCP cannot execute write or dangerous Redis commands against a production database.");
}
try {
const result = await backend.executeRedisCommand(scopedConfig, defaultRedisDb(scopedConfig, scope, db), command, {
skipSafetyCheck: safety.skipSafetyCheck,
});
return labeledText(scopedConfig, formatRedisCommandToolResult(result).content[0].text);
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : String(e);
return toolError("REDIS_COMMAND_ERROR", msg);
}
},
);
server.tool(
"dbx_get_schema_context",
"Get compact table and column context for writing SQL",
{
connection_id: z.string().optional().describe("Unique ID of the DBX connection (use this to disambiguate when multiple connections share the same name)"),
connection_name: z.string().optional().describe("Name of the DBX connection"),
database: z.string().optional().describe("Database name"),
schema: z.string().optional().describe("Schema name (default: public for PostgreSQL)"),
tables: z.array(z.string()).optional().describe("Specific table names to include"),
max_tables: z.number().int().min(1).max(20).default(8).describe("Maximum number of tables to include"),
},
async ({ connection_id, connection_name, database, schema, tables, max_tables }) => {
const { config, error } = await resolveConnection(backend, scope, connection_id, connection_name);
if (error) return error;
const resolvedConfig = config!;
const context = await buildSchemaContext(backend, withDatabase(resolvedConfig, database ?? scope.database), {
schema,
tables,
maxTables: max_tables,
});
if (context.tables.length === 0) return text("No matching tables found.");
return labeledText(resolvedConfig, formatSchemaContext(context));
},
);
if (!scoped) {
server.tool(
"dbx_add_connection",
"Add a new database connection to DBX",
{
name: z.string().describe("Connection name"),
db_type: z.string().describe(DBX_CONNECTION_TYPE_DESCRIPTION),
host: z.string().describe("Database host; for cloudflare-d1, use the Cloudflare Account ID"),
port: z.number().optional().describe("Database port (TDengine defaults to 6041, IoTDB defaults to 6667, XuguDB defaults to 5138)"),
username: z.string().default("").describe("Username"),
password: z.string().default("").describe("Password; for cloudflare-d1, use the API Token"),
database: z.string().optional().describe("Default database name; for cloudflare-d1, use the D1 Database ID"),
ssl: z.boolean().default(false).describe("Enable SSL"),
driver_profile: z.string().optional().describe("Driver profile (e.g. 'gbase8a', 'gbase8s')"),
},
async ({ name, db_type, host, port, username, password, database, ssl, driver_profile }) => {
const existing = await backend.findConnection(name);
if (existing) return text(`Connection "${name}" already exists.`);
const DEFAULT_PORTS: Record<string, number> = {
kwdb: 26257,
rqlite: 4001,
"cloudflare-d1": 443,
tdengine: 6041,
oscar: 2003,
iotdb: 6667,
xugu: 5138,
};
const resolvedPort = port ?? DEFAULT_PORTS[db_type] ?? (FILE_CAPABLE_CONNECTION_TYPES.has(db_type) ? 0 : undefined);
if (resolvedPort === undefined) return text("Port is required for this database type.");
const config = await backend.addConnection({
name,
db_type,
host,
port: resolvedPort,
username,
password,
database,
ssl,
driver_profile,
ssh_enabled: false,
} as Omit<ConnectionConfig, "id">);
await notifyReload();
return text(`Connection "${config.name}" added (id: ${config.id}).`);
},
);
server.tool(
"dbx_remove_connection",
"Remove a database connection from DBX",
{
connection_name: z.string().describe("Name of the connection to remove"),
connection_id: z.string().optional().describe("Unique ID of the DBX connection (use this to remove by id instead of name)"),
},
async ({ connection_name, connection_id }) => {
if (connection_id?.trim()) {
if (backend.removeConnectionById) {
const removed = await backend.removeConnectionById(connection_id.trim());
if (!removed) return toolError("CONNECTION_NOT_FOUND", `Connection with id "${connection_id}" not found.`);
await notifyReload();
return text(`Connection with id "${connection_id}" removed.`);
}
// Fallback: resolve by id then remove by name
const connections = await backend.loadConnections();
const config = connections.find((c) => c.id === connection_id.trim());
if (!config) return toolError("CONNECTION_NOT_FOUND", `Connection with id "${connection_id}" not found.`);
const removed = await backend.removeConnection(config.name);
if (!removed) return toolError("CONNECTION_NOT_FOUND", `Connection "${config.name}" could not be removed.`);
await notifyReload();
return text(`Connection "${config.name}" (id: ${config.id}) removed.`);
}
const allConnections = await backend.loadConnections();
const matching = allConnections.filter((c) => c.name.toLowerCase() === connection_name.toLowerCase());
if (matching.length === 0) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
if (matching.length > 1) {
const lines = matching.map((c) => `- ${c.id}: ${c.db_type} @ ${c.host}:${c.port}`);
return toolError("AMBIGUOUS_CONNECTION", `Multiple connections found with name "${connection_name}". Please specify connection_id:\n${lines.join("\n")}`);
}
const removed = await backend.removeConnection(connection_name);
if (!removed) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
await notifyReload();
return text(`Connection "${connection_name}" removed.`);
},
);
}
// Desktop-only tools: open table and execute-and-show require the Tauri bridge
if (!isWebMode && !scoped) {
server.tool(
"dbx_open_table",
"Open a table in DBX desktop app UI. Requires DBX to be running.",
{
connection_id: z.string().optional().describe("Unique ID of the DBX connection (use this to disambiguate when multiple connections share the same name)"),
connection_name: z.string().optional().describe("Name of the DBX connection"),
table: z.string().describe("Table name to open"),
database: z.string().optional().describe("Database name"),
schema: z.string().optional().describe("Schema name"),
},
async ({ connection_id, connection_name, table, database, schema }) => {
let config: ConnectionConfig | undefined;
if (connection_id?.trim()) {
const connections = await backend.loadConnections();
config = connections.find((c) => c.id === connection_id.trim());
if (!config) return toolError("CONNECTION_NOT_FOUND", `Connection with id "${connection_id}" not found.`);
} else if (connection_name?.trim()) {
const connections = await backend.loadConnections();
const matching = connections.filter((c) => c.name.toLowerCase() === connection_name.toLowerCase());
if (matching.length === 0) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
if (matching.length > 1) {
const lines = matching.map((c) => `- ${c.id}: ${c.db_type} @ ${c.host}:${c.port}`);
return toolError("AMBIGUOUS_CONNECTION", `Multiple connections found with name "${connection_name}". Please specify connection_id:\n${lines.join("\n")}`);
}
config = matching[0];
} else {
return toolError("CONNECTION_NOT_FOUND", "Either connection_id or connection_name is required.");
}
return bridgeRequest("/open-table", { connection_id: config.id, connection_name: config.name, table, database, schema }, `Opened ${table} in DBX`);
},
);
server.tool(
"dbx_execute_and_show",
"Execute a SQL query in DBX desktop app UI and show results there. Requires DBX to be running.",
{
connection_id: z.string().optional().describe("Unique ID of the DBX connection (use this to disambiguate when multiple connections share the same name)"),
connection_name: z.string().optional().describe("Name of the DBX connection"),
sql: z.string().describe("SQL query to execute"),
database: z.string().optional().describe("Database name"),
},
async ({ connection_id, connection_name, sql, database }) => {
let config: ConnectionConfig | undefined;
if (connection_id?.trim()) {
const connections = await backend.loadConnections();
config = connections.find((c) => c.id === connection_id.trim());
if (!config) return toolError("CONNECTION_NOT_FOUND", `Connection with id "${connection_id}" not found.`);
} else if (connection_name?.trim()) {
const connections = await backend.loadConnections();
const matching = connections.filter((c) => c.name.toLowerCase() === connection_name.toLowerCase());
if (matching.length === 0) return toolError("CONNECTION_NOT_FOUND", `Connection "${connection_name}" not found.`);
if (matching.length > 1) {
const lines = matching.map((c) => `- ${c.id}: ${c.db_type} @ ${c.host}:${c.port}`);
return toolError("AMBIGUOUS_CONNECTION", `Multiple connections found with name "${connection_name}". Please specify connection_id:\n${lines.join("\n")}`);
}
config = matching[0];
} else {
return toolError("CONNECTION_NOT_FOUND", "Either connection_id or connection_name is required.");
}
const safetyOptions = sqlSafetyFromEnv();
if (config?.db_type === "mongodb") {
const aggregate = parseMongoAggregateCommand(sql);
if (aggregate) {
const safety = evaluateMongoAggregateSafety(aggregate, safetyOptions);
if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "Query blocked.");
}
} else {
const hashLineComments = supportsHashLineComments(config?.db_type);
const safety = evaluateSqlSafety(sql, { ...safetyOptions, allowMultipleStatements: true, hashLineComments });
if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "SQL blocked.");
}
if (config?.db_type === "mongodb") {
if (isProductionDatabase(config, database ?? scope.database ?? config.database) && isLikelyMongoMutation(sql)) {
return toolError("PRODUCTION_WRITE_BLOCKED", "MCP cannot send writes against a production database to DBX.");
}
} else {
const production = assessProductionSql(sql, config, database ?? scope.database ?? config.database);
if (production.active && production.isMutation) {
return toolError("PRODUCTION_WRITE_BLOCKED", "MCP cannot send writes against a production database to DBX.");
}
}
// MongoDB shell commands bypass the SQL safety evaluator; pass MCP
// safety flags to the desktop executor for command-aware gating.
logSqlDiagnostic("dbx_execute_in_app", sql, { connection_id: config!.id, connection_name: config!.name, database });
return bridgeRequest(
"/execute-query",
{
connection_id: config!.id,
connection_name: config!.name,
sql,
database,
allow_writes: safetyOptions.allowWrites,
allow_dangerous: safetyOptions.allowDangerous,
},
"Query sent to DBX",
);
},
);
}
return server;
}
async function bridgeRequest(path: string, body: Record<string, unknown>, successMsg: string) {
const res = await postBridge(path, body);
if (res.ok) return text(successMsg);
const message = res.text.startsWith("DBX is not running") ? res.text : `Failed: ${res.text}`;
return toolError("DBX_NOT_RUNNING", message);
}
async function main() {
const backend = await createBackend();
const server = createDbxMcpServer(backend);
const transport = new StdioServerTransport();
await server.connect(transport);
}
if (isMainModule(import.meta.url, process.argv[1])) {
main().catch((e) => {
console.error("MCP Server failed to start:", e);
process.exit(1);
});
}

View File

@ -1,57 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { test } from "vitest";
import { DBX_CONNECTION_TYPE_DESCRIPTION } from "../src/index.js";
interface DriverManifest {
drivers: Array<{
dbType: string;
supportLevel: "connect" | "browse" | "understand" | "operate";
capabilities: Record<DatabaseProductCapability, boolean>;
}>;
}
const PRODUCT_CAPABILITY_KEYS = [
"queryExecution",
"metadataBrowse",
"objectBrowser",
"objectSource",
"schemaSearch",
"diagram",
"tableDataEdit",
"tableStructureEdit",
"tableImport",
"dataTransfer",
"sqlFileExecution",
"databaseCreate",
"fieldLineage",
"sqlExplain",
"userAdmin",
"driverManagement",
] as const;
type DatabaseProductCapability = (typeof PRODUCT_CAPABILITY_KEYS)[number];
function loadManifest(): DriverManifest {
const path = fileURLToPath(new URL("../../../crates/dbx-core/assets/database-drivers.manifest.json", import.meta.url));
return JSON.parse(readFileSync(path, "utf8")) as DriverManifest;
}
test("add connection database type description includes every manifest database type", () => {
const manifest = loadManifest();
for (const driver of manifest.drivers) {
assert.match(DBX_CONNECTION_TYPE_DESCRIPTION, new RegExp(`\\b${driver.dbType}\\b`));
}
});
test("driver manifest includes product capability metadata for every database type", () => {
const manifest = loadManifest();
for (const driver of manifest.drivers) {
assert.match(driver.supportLevel, /^(connect|browse|understand|operate)$/);
for (const key of PRODUCT_CAPABILITY_KEYS) {
assert.equal(typeof driver.capabilities[key], "boolean", `${driver.dbType}.${key} should be a boolean`);
}
}
});

View File

@ -1,919 +0,0 @@
import assert from "node:assert/strict";
import { mkdtemp, rm, readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "vitest";
import type { Backend, ConnectionConfig } from "@dbx-app/node-core";
import { createDbxMcpServer, DBX_MCP_PACKAGE_VERSION } from "../src/index.js";
const connection: ConnectionConfig = {
id: "1",
name: "local",
db_type: "postgres",
host: "127.0.0.1",
port: 5432,
username: "app",
password: "",
database: "demo",
ssh_enabled: false,
ssl: false,
};
const backend: Backend = {
loadConnections: async () => [connection],
findConnection: async (name) => (name === "local" ? connection : undefined),
addConnection: async () => connection,
removeConnection: async () => true,
listTables: async () => [{ name: "users", type: "BASE TABLE" }],
describeTable: async () => [{ name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, comment: null }],
executeQuery: async () => ({ columns: ["total"], rows: [{ total: 1 }], row_count: 1 }),
};
async function withScopedEnv<T>(env: Record<string, string>, fn: () => T | Promise<T>): Promise<T> {
const oldValues = new Map<string, string | undefined>();
for (const key of Object.keys(env)) {
oldValues.set(key, process.env[key]);
process.env[key] = env[key];
}
try {
return await fn();
} finally {
for (const [key, value] of oldValues) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
}
}
test("creates an MCP server without starting stdio transport", () => {
const server = createDbxMcpServer(backend, { isWebMode: true });
assert.equal(typeof server.connect, "function");
});
test("MCP server metadata version matches package metadata", () => {
const server = createDbxMcpServer(backend, { isWebMode: true });
assert.equal((server as any).server._serverInfo.version, DBX_MCP_PACKAGE_VERSION);
});
test("README runtime requirements match package engines", async () => {
const packageJson = JSON.parse(await readFile(new URL("../package.json", import.meta.url), "utf-8")) as {
engines: { node: string };
};
const readme = await readFile(new URL("../README.md", import.meta.url), "utf-8");
const minimumNodeVersion = packageJson.engines.node.replace(">=", "");
assert.match(readme, new RegExp(`Node\\.js ${minimumNodeVersion.replace(/\./g, "\\.")} or newer`));
assert.match(readme, new RegExp(`Node\\.js ${minimumNodeVersion.replace(/\./g, "\\.")} 或更高版本`));
});
test("execute query scopes the connection to the requested database", async () => {
let usedDatabase = "";
const scopedBackend: Backend = {
...backend,
executeQuery: async (config) => {
usedDatabase = config.database || "";
return { columns: ["total"], rows: [{ total: 1 }], row_count: 1 };
},
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
await (server as any)._registeredTools.dbx_execute_query.handler({
connection_name: "local",
database: "stores_demo",
sql: "SELECT FIRST 1 tabname FROM systables",
});
assert.equal(usedDatabase, "stores_demo");
});
test("execute query runs safe multi-statement SQL one statement at a time", async () => {
const executed: string[] = [];
const scopedBackend: Backend = {
...backend,
executeQuery: async (_config, sql) => {
executed.push(sql);
return { columns: ["value"], rows: [{ value: executed.length }], row_count: 1 };
},
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
const result = await (server as any)._registeredTools.dbx_execute_query.handler({
connection_name: "local",
sql: "select 1; select 2;",
});
assert.deepEqual(executed, ["select 1", "select 2"]);
assert.match(result.content[0].text, /Statement 1/);
assert.match(result.content[0].text, /Statement 2/);
});
test("execute query preserves string literals and PostgreSQL dollar quotes", async () => {
const executed: string[] = [];
const scopedBackend: Backend = {
...backend,
executeQuery: async (_config, sql) => {
executed.push(sql);
return { columns: ["value"], rows: [{ value: 1 }], row_count: 1 };
},
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
await (server as any)._registeredTools.dbx_execute_query.handler({
connection_name: "local",
sql: "SELECT 'a;b' AS first; SELECT $tag$c;d$tag$ AS second;",
});
assert.deepEqual(executed, ["SELECT 'a;b' AS first", "SELECT $tag$c;d$tag$ AS second"]);
});
test("execute query reports the blocked statement number for unsafe multi-statement SQL", async () => {
const server = createDbxMcpServer(backend, { isWebMode: true });
const result = await (server as any)._registeredTools.dbx_execute_query.handler({
connection_name: "local",
sql: "select 1; delete from users;",
});
assert.equal(result.isError, true);
assert.match(result.content[0].text, /SQL_BLOCKED:/);
assert.match(result.content[0].text, /Statement 2/);
assert.match(result.content[0].text, /WHERE/);
});
test("scoped MCP lists only the active connection", async () => {
const other: ConnectionConfig = { ...connection, id: "2", name: "other", database: "other_db" };
const scopedBackend: Backend = {
...backend,
loadConnections: async () => [connection, other],
};
const result = await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1" }, () => {
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
return (server as any)._registeredTools.dbx_list_connections.handler({});
});
assert.match(result.content[0].text, /local/);
assert.doesNotMatch(result.content[0].text, /other/);
});
test("scoped MCP rejects out-of-scope connection tool calls", async () => {
const result = await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1" }, () => {
const server = createDbxMcpServer(backend, { isWebMode: true });
return (server as any)._registeredTools.dbx_list_tables.handler({ connection_name: "other" });
});
assert.equal(result.isError, true);
assert.match(result.content[0].text, /CONNECTION_OUT_OF_SCOPE:/);
});
test("scoped MCP defaults connection-taking tools to the active connection and database", async () => {
let usedDatabase = "";
const scopedBackend: Backend = {
...backend,
listTables: async (config) => {
usedDatabase = config.database || "";
return [{ name: "users", type: "BASE TABLE" }];
},
};
const result = await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1", DBX_MCP_SCOPE_DATABASE: "scoped_db" }, () => {
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
return (server as any)._registeredTools.dbx_list_tables.handler({});
});
assert.match(result.content[0].text, /users/);
assert.equal(usedDatabase, "scoped_db");
});
test("scoped MCP does not register mutation or desktop bridge tools", async () => {
await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1" }, () => {
const server = createDbxMcpServer(backend, { isWebMode: false });
const tools = (server as any)._registeredTools;
assert.equal(tools.dbx_add_connection, undefined);
assert.equal(tools.dbx_remove_connection, undefined);
assert.equal(tools.dbx_open_table, undefined);
assert.equal(tools.dbx_execute_and_show, undefined);
});
});
test("scoped MCP with writes disabled blocks write SQL", async () => {
const result = await withScopedEnv({ DBX_MCP_SCOPE_CONNECTION_ID: "1", DBX_MCP_ALLOW_WRITES: "0" }, () => {
const server = createDbxMcpServer(backend, { isWebMode: true });
return (server as any)._registeredTools.dbx_execute_query.handler({
sql: "update users set name = 'x' where id = 1",
});
});
assert.equal(result.isError, true);
assert.match(result.content[0].text, /SQL_BLOCKED:/);
});
test("redis execute query points callers to the redis command tool", async () => {
const redisConnection: ConnectionConfig = { ...connection, db_type: "redis", database: "0" };
const scopedBackend: Backend = {
...backend,
loadConnections: async () => [redisConnection],
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
const result = await (server as any)._registeredTools.dbx_execute_query.handler({
connection_name: "local",
sql: "GET session:1",
});
assert.equal(result.isError, true);
assert.match(result.content[0].text, /REDIS_COMMAND_REQUIRED:/);
assert.match(result.content[0].text, /dbx_execute_redis_command/);
});
test("redis command tool executes redis commands on the selected database", async () => {
const redisConnection: ConnectionConfig = { ...connection, db_type: "redis", database: "2" };
let usedDb = -1;
let usedCommand = "";
const scopedBackend: Backend = {
...backend,
loadConnections: async () => [redisConnection],
executeRedisCommand: async (_config, db, command) => {
usedDb = db;
usedCommand = command;
return { command: "GET", safety: "allowed", value: "value-1" };
},
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
const result = await (server as any)._registeredTools.dbx_execute_redis_command.handler({
connection_name: "local",
command: "GET session:1",
});
assert.equal(result.isError, undefined);
assert.equal(usedDb, 2);
assert.equal(usedCommand, "GET session:1");
assert.match(result.content[0].text, /Command: GET/);
assert.match(result.content[0].text, /value-1/);
});
test("dbx_execute_query does not log SQL when debug diagnostics are disabled", async () => {
const original = console.error;
const originalDebug = process.env.DBX_SQL_DEBUG;
const originalMcpDebug = process.env.DBX_MCP_DEBUG_SQL;
const messages: unknown[][] = [];
delete process.env.DBX_SQL_DEBUG;
delete process.env.DBX_MCP_DEBUG_SQL;
console.error = (...args: unknown[]) => messages.push(args);
try {
const server = createDbxMcpServer(backend, { isWebMode: true });
const result = await (server as any)._registeredTools.dbx_execute_query.handler({
connection_name: "local",
sql: "select 'secret-123' as token",
});
assert.equal(result.isError, undefined);
} finally {
console.error = original;
if (originalDebug === undefined) delete process.env.DBX_SQL_DEBUG;
else process.env.DBX_SQL_DEBUG = originalDebug;
if (originalMcpDebug === undefined) delete process.env.DBX_MCP_DEBUG_SQL;
else process.env.DBX_MCP_DEBUG_SQL = originalMcpDebug;
}
assert.equal(messages.length, 0);
});
test("dbx_execute_query omits raw SQL from user-facing query errors", async () => {
const sensitiveSql = "select 'secret-123' as token";
const scopedBackend: Backend = {
...backend,
executeQuery: async () => {
throw new Error("driver rejected statement");
},
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
const result = await (server as any)._registeredTools.dbx_execute_query.handler({
connection_name: "local",
sql: sensitiveSql,
});
assert.equal(result.isError, true);
assert.match(result.content[0].text, /QUERY_ERROR: driver rejected statement/);
assert.doesNotMatch(result.content[0].text, /secret-123|SQL:/);
});
test("redis command tool blocks write commands in read-only MCP sessions", async () => {
let executed = false;
const redisConnection: ConnectionConfig = { ...connection, db_type: "redis" };
const scopedBackend: Backend = {
...backend,
loadConnections: async () => [redisConnection],
executeRedisCommand: async () => {
executed = true;
return { command: "SET", safety: "write", value: "OK" };
},
};
const result = await withScopedEnv({ DBX_MCP_ALLOW_WRITES: "0" }, () => {
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
return (server as any)._registeredTools.dbx_execute_redis_command.handler({
connection_name: "local",
command: "SET session:1 value",
});
});
assert.equal(executed, false);
assert.equal(result.isError, true);
assert.match(result.content[0].text, /REDIS_COMMAND_BLOCKED:/);
});
test("redis command tool allows dangerous redis commands only when explicitly enabled", async () => {
const redisConnection: ConnectionConfig = { ...connection, db_type: "redis" };
let skipSafetyCheck = false;
const scopedBackend: Backend = {
...backend,
loadConnections: async () => [redisConnection],
executeRedisCommand: async (_config, _db, _command, options) => {
skipSafetyCheck = options?.skipSafetyCheck ?? false;
return { command: "KEYS", safety: "blocked", value: ["session:1"] };
},
};
const blocked = await withScopedEnv({ DBX_MCP_ALLOW_DANGEROUS_SQL: "0" }, () => {
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
return (server as any)._registeredTools.dbx_execute_redis_command.handler({
connection_name: "local",
command: "KEYS *",
});
});
const allowed = await withScopedEnv({ DBX_MCP_ALLOW_DANGEROUS_SQL: "1" }, () => {
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
return (server as any)._registeredTools.dbx_execute_redis_command.handler({
connection_name: "local",
command: "KEYS *",
});
});
assert.equal(blocked.isError, true);
assert.match(blocked.content[0].text, /REDIS_COMMAND_BLOCKED:/);
assert.equal(allowed.isError, undefined);
assert.equal(skipSafetyCheck, true);
assert.match(allowed.content[0].text, /session:1/);
});
test("mongodb list tables returns collections from the selected database", async () => {
let usedDatabase = "";
const mongoConnection: ConnectionConfig = { ...connection, db_type: "mongodb", database: "admin" };
const scopedBackend: Backend = {
...backend,
loadConnections: async () => [mongoConnection],
listTables: async (config) => {
usedDatabase = config.database || "";
return [{ name: "projects", type: "COLLECTION" }];
},
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
const result = await (server as any)._registeredTools.dbx_list_tables.handler({
connection_name: "local",
database: "pystrument",
});
assert.equal(usedDatabase, "pystrument");
assert.match(result.content[0].text, /projects/);
assert.match(result.content[0].text, /COLLECTION/);
});
test("mongodb describe table returns inferred document fields", async () => {
const mongoConnection: ConnectionConfig = { ...connection, db_type: "mongodb" };
const scopedBackend: Backend = {
...backend,
loadConnections: async () => [mongoConnection],
describeTable: async () => [
{
name: "_id",
data_type: "object",
is_nullable: false,
column_default: null,
is_primary_key: true,
comment: null,
},
{
name: "name",
data_type: "string",
is_nullable: false,
column_default: null,
is_primary_key: false,
comment: null,
},
],
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
const result = await (server as any)._registeredTools.dbx_describe_table.handler({
connection_name: "local",
database: "pystrument",
table: "projects",
});
assert.match(result.content[0].text, /_id \(PK\)/);
assert.match(result.content[0].text, /name/);
});
test("dameng metadata tools default to the login user schema", async () => {
const damengConnection: ConnectionConfig = {
...connection,
db_type: "dameng",
username: "SYSDBA",
database: "DAMENG",
};
const usedScopes: Array<{ database?: string; schema?: string }> = [];
const scopedBackend: Backend = {
...backend,
loadConnections: async () => [damengConnection],
listTables: async (config, schema) => {
usedScopes.push({ database: config.database, schema });
return [{ name: "ORDERS", type: "TABLE" }];
},
describeTable: async (config, _table, schema) => {
usedScopes.push({ database: config.database, schema });
return [{ name: "ID", data_type: "BIGINT", is_nullable: false, column_default: null, is_primary_key: true, comment: null }];
},
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
await (server as any)._registeredTools.dbx_list_tables.handler({ connection_name: "local" });
await (server as any)._registeredTools.dbx_describe_table.handler({ connection_name: "local", table: "ORDERS" });
assert.deepEqual(usedScopes, [
{ database: "DAMENG", schema: "SYSDBA" },
{ database: "DAMENG", schema: "SYSDBA" },
]);
});
test("dameng metadata tools treat database as a schema alias while preferring explicit schema", async () => {
const damengConnection: ConnectionConfig = { ...connection, db_type: "dameng", username: "SYSDBA", database: "DAMENG" };
let usedSchema = "";
const scopedBackend: Backend = {
...backend,
loadConnections: async () => [damengConnection],
listTables: async (_config, schema) => {
usedSchema = schema || "";
return [{ name: "ORDERS", type: "TABLE" }];
},
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
await (server as any)._registeredTools.dbx_list_tables.handler({ connection_name: "local", database: "XC" });
assert.equal(usedSchema, "XC");
await (server as any)._registeredTools.dbx_list_tables.handler({ connection_name: "local", database: "XC", schema: "REPORTING" });
assert.equal(usedSchema, "REPORTING");
});
test("mongodb execute query formats shell-style find results", async () => {
const mongoConnection: ConnectionConfig = { ...connection, db_type: "mongodb" };
const scopedBackend: Backend = {
...backend,
loadConnections: async () => [mongoConnection],
executeQuery: async () => ({
columns: ["_id", "meta", "missing"],
rows: [{ _id: "1", meta: { name: "demo" }, missing: null }],
row_count: 1,
}),
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
const result = await (server as any)._registeredTools.dbx_execute_query.handler({
connection_name: "local",
database: "pystrument",
sql: "db.projects.find({}).limit(1)",
});
assert.match(result.content[0].text, /"name":"demo"/);
assert.match(result.content[0].text, /NULL/);
assert.match(result.content[0].text, /1 row\(s\)/);
});
test("connection lookup failures include a stable MCP error code", async () => {
const server = createDbxMcpServer(backend, { isWebMode: true });
const result = await (server as any)._registeredTools.dbx_list_tables.handler({
connection_name: "missing",
});
assert.equal(result.isError, true);
assert.match(result.content[0].text, /CONNECTION_NOT_FOUND:/);
assert.match(result.content[0].text, /missing/);
});
test("add connection accepts H2 file paths without a port", async () => {
let added: Omit<ConnectionConfig, "id"> | undefined;
const scopedBackend: Backend = {
...backend,
addConnection: async (config) => {
added = config;
return { id: "h2-file", ...config };
},
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
const result = await (server as any)._registeredTools.dbx_add_connection.handler({
name: "h2-local",
db_type: "h2",
host: "/data/app.mv.db",
username: "sa",
password: "",
});
assert.equal(result.isError, undefined);
assert.equal(added?.db_type, "h2");
assert.equal(added?.host, "/data/app.mv.db");
assert.equal(added?.port, 0);
});
test("SQL safety failures include a stable MCP error code", async () => {
const server = createDbxMcpServer(backend, { isWebMode: true });
const result = await (server as any)._registeredTools.dbx_execute_query.handler({
connection_name: "local",
sql: "drop table users",
});
assert.equal(result.isError, true);
assert.match(result.content[0].text, /SQL_BLOCKED:/);
assert.match(result.content[0].text, /Dangerous SQL/);
});
test("query exceptions include a stable MCP error code", async () => {
const scopedBackend: Backend = {
...backend,
executeQuery: async () => {
throw new Error("database timeout");
},
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
const result = await (server as any)._registeredTools.dbx_execute_query.handler({
connection_name: "local",
sql: "select 1",
});
assert.equal(result.isError, true);
assert.match(result.content[0].text, /QUERY_ERROR: database timeout/);
});
test("desktop bridge failures include a stable MCP error code", async () => {
const dir = await mkdtemp(join(tmpdir(), "dbx-mcp-home-"));
try {
// Use DBX_DATA_DIR (honoured cross-platform) to point bridgePortFilePath()
// at an empty temp directory so no real bridge is reachable.
await withScopedEnv({ DBX_DATA_DIR: dir }, async () => {
const server = createDbxMcpServer(backend, { isWebMode: false });
const result = await (server as any)._registeredTools.dbx_open_table.handler({
connection_name: "local",
table: "users",
});
assert.equal(result.isError, true);
assert.match(result.content[0].text, /DBX_NOT_RUNNING:/);
assert.match(result.content[0].text, /DBX is not running/);
});
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("mongodb execute-and-show blocks aggregate write stages before desktop bridge", async () => {
const oldAllowWrites = process.env.DBX_MCP_ALLOW_WRITES;
const oldAllowDangerous = process.env.DBX_MCP_ALLOW_DANGEROUS_SQL;
delete process.env.DBX_MCP_ALLOW_WRITES;
delete process.env.DBX_MCP_ALLOW_DANGEROUS_SQL;
const mongoConnection: ConnectionConfig = { ...connection, db_type: "mongodb" };
const scopedBackend: Backend = {
...backend,
loadConnections: async () => [mongoConnection],
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: false });
try {
const result = await (server as any)._registeredTools.dbx_execute_and_show.handler({
connection_name: "local",
database: "pystrument",
sql: 'db.projects.aggregate([{"$out":"projects_dump"}])',
});
assert.match(result.content[0].text, /SQL_BLOCKED:/);
assert.match(result.content[0].text, /DBX_MCP_ALLOW_DANGEROUS_SQL=1/);
} finally {
if (oldAllowWrites === undefined) delete process.env.DBX_MCP_ALLOW_WRITES;
else process.env.DBX_MCP_ALLOW_WRITES = oldAllowWrites;
if (oldAllowDangerous === undefined) delete process.env.DBX_MCP_ALLOW_DANGEROUS_SQL;
else process.env.DBX_MCP_ALLOW_DANGEROUS_SQL = oldAllowDangerous;
}
});
test("connection_id parameter resolves correctly", async () => {
const connA: ConnectionConfig = { ...connection, id: "a1b2c3", name: "shared-name", db_type: "postgres" };
const connB: ConnectionConfig = { ...connection, id: "d4e5f6", name: "shared-name", db_type: "redis", host: "redis.local", port: 6379 };
const scopedBackend: Backend = {
...backend,
loadConnections: async () => [connA, connB],
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
// Resolve by connection_id should return the correct connection
const result = await (server as any)._registeredTools.dbx_list_tables.handler({
connection_id: "d4e5f6",
});
assert.match(result.content[0].text, /users/);
});
test("duplicate connection names return AMBIGUOUS_CONNECTION error", async () => {
const connA: ConnectionConfig = { ...connection, id: "a1b2c3", name: "shared-name", db_type: "postgres", host: "pg.local", port: 5432 };
const connB: ConnectionConfig = { ...connection, id: "d4e5f6", name: "shared-name", db_type: "redis", host: "redis.local", port: 6379 };
const scopedBackend: Backend = {
...backend,
loadConnections: async () => [connA, connB],
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
// Using connection_name with duplicates should return AMBIGUOUS_CONNECTION
const result = await (server as any)._registeredTools.dbx_list_tables.handler({
connection_name: "shared-name",
});
assert.equal(result.isError, true);
assert.match(result.content[0].text, /AMBIGUOUS_CONNECTION:/);
assert.match(result.content[0].text, /a1b2c3:/);
assert.match(result.content[0].text, /d4e5f6:/);
assert.match(result.content[0].text, /postgres @ pg.local:5432/);
assert.match(result.content[0].text, /redis @ redis.local:6379/);
});
test("connection_id takes priority over connection_name", async () => {
const connA: ConnectionConfig = { ...connection, id: "a1b2c3", name: "shared-name", db_type: "postgres" };
const connB: ConnectionConfig = { ...connection, id: "d4e5f6", name: "shared-name", db_type: "mysql", host: "mysql.local", port: 3306 };
let usedConfig: ConnectionConfig | undefined;
const scopedBackend: Backend = {
...backend,
loadConnections: async () => [connA, connB],
listTables: async (config) => {
usedConfig = config;
return [{ name: "users", type: "BASE TABLE" }];
},
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
// Provide both connection_id and connection_name; connection_id should win
await (server as any)._registeredTools.dbx_list_tables.handler({
connection_id: "d4e5f6",
connection_name: "shared-name",
});
assert.equal(usedConfig?.id, "d4e5f6");
});
test("dbx_list_connections includes ID column", async () => {
const server = createDbxMcpServer(backend, { isWebMode: true });
const result = await (server as any)._registeredTools.dbx_list_connections.handler({});
// The table header should include the ID column
assert.match(result.content[0].text, /ID.*Name.*Type.*Host.*Port.*Database/);
// The connection's ID value "1" should appear in the table
assert.match(result.content[0].text, /1\s+\|\s+local/);
});
test("same name and db_type with different host/port returns AMBIGUOUS_CONNECTION", async () => {
const connA: ConnectionConfig = {
...connection,
id: "pg-prod-us",
name: "my-db",
db_type: "postgres",
host: "10.0.1.100",
port: 5432,
database: "app",
};
const connB: ConnectionConfig = {
...connection,
id: "pg-prod-eu",
name: "my-db",
db_type: "postgres",
host: "10.0.2.200",
port: 5432,
database: "app",
};
const scopedBackend: Backend = {
...backend,
loadConnections: async () => [connA, connB],
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
// Using connection_name with duplicates (same db_type) should still return AMBIGUOUS_CONNECTION
const result = await (server as any)._registeredTools.dbx_list_tables.handler({
connection_name: "my-db",
});
assert.equal(result.isError, true);
assert.match(result.content[0].text, /AMBIGUOUS_CONNECTION:/);
assert.match(result.content[0].text, /pg-prod-us: postgres @ 10\.0\.1\.100:5432/);
assert.match(result.content[0].text, /pg-prod-eu: postgres @ 10\.0\.2\.200:5432/);
});
test("connection_id routes to correct host among same-name same-type connections", async () => {
const connA: ConnectionConfig = {
...connection,
id: "pg-prod-us",
name: "my-db",
db_type: "postgres",
host: "10.0.1.100",
port: 5432,
database: "app",
};
const connB: ConnectionConfig = {
...connection,
id: "pg-prod-eu",
name: "my-db",
db_type: "postgres",
host: "10.0.2.200",
port: 5432,
database: "app",
};
const usedConfigs: ConnectionConfig[] = [];
const scopedBackend: Backend = {
...backend,
loadConnections: async () => [connA, connB],
listTables: async (config) => {
usedConfigs.push(config);
return [{ name: "orders", type: "BASE TABLE" }];
},
executeQuery: async (config, _sql) => {
usedConfigs.push(config);
return { columns: ["cnt"], rows: [{ cnt: 42 }], row_count: 1 };
},
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
// Route to US instance via connection_id
const listResult = await (server as any)._registeredTools.dbx_list_tables.handler({
connection_id: "pg-prod-us",
});
assert.match(listResult.content[0].text, /orders/);
assert.equal(usedConfigs[0].id, "pg-prod-us");
assert.equal(usedConfigs[0].host, "10.0.1.100");
// Route to EU instance via connection_id
const queryResult = await (server as any)._registeredTools.dbx_execute_query.handler({
connection_id: "pg-prod-eu",
database: "app",
sql: "select count(*) as cnt from orders",
});
assert.match(queryResult.content[0].text, /42/);
assert.equal(usedConfigs[1].id, "pg-prod-eu");
assert.equal(usedConfigs[1].host, "10.0.2.200");
});
test("tool responses are prefixed with connection identity label", async () => {
const scopedBackend: Backend = {
...backend,
loadConnections: async () => [{ ...connection, id: "conn-xyz", name: "orders-db", db_type: "postgres", host: "10.5.5.5", port: 5432 }],
listTables: async () => [{ name: "orders", type: "BASE TABLE" }],
executeQuery: async () => ({ columns: ["cnt"], rows: [{ cnt: 7 }], row_count: 1 }),
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
const listResult = await (server as any)._registeredTools.dbx_list_tables.handler({ connection_id: "conn-xyz" });
assert.match(listResult.content[0].text, /^\[orders-db \(conn-xyz\) \[postgres @ 10\.5\.5\.5:5432\]\]/);
const queryResult = await (server as any)._registeredTools.dbx_execute_query.handler({ connection_id: "conn-xyz", sql: "select count(*) as cnt from orders" });
assert.match(queryResult.content[0].text, /^\[orders-db \(conn-xyz\) \[postgres @ 10\.5\.5\.5:5432\]\]/);
});
test("dbx_remove_connection with duplicate names returns AMBIGUOUS_CONNECTION", async () => {
const connA: ConnectionConfig = { ...connection, id: "db-a", name: "staging", db_type: "postgres", host: "pg-a.local" };
const connB: ConnectionConfig = { ...connection, id: "db-b", name: "staging", db_type: "mysql", host: "mysql-b.local", port: 3306 };
let removedName: string | undefined;
const scopedBackend: Backend = {
...backend,
loadConnections: async () => [connA, connB],
removeConnection: async (name) => {
removedName = name;
return true;
},
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
const result = await (server as any)._registeredTools.dbx_remove_connection.handler({
connection_name: "staging",
});
assert.equal(result.isError, true);
assert.match(result.content[0].text, /AMBIGUOUS_CONNECTION:/);
assert.match(result.content[0].text, /db-a: postgres @ pg-a\.local/);
assert.match(result.content[0].text, /db-b: mysql @ mysql-b\.local/);
// removeConnection must NOT have been called — no silent deletion
assert.equal(removedName, undefined);
});
test("dbx_execute_query with connection_id routes correctly on bridge-backed (SSH) connections", async () => {
const connDirect: ConnectionConfig = { ...connection, id: "pg-direct", name: "shared", db_type: "postgres", host: "direct.local", ssh_enabled: false };
const connSsh: ConnectionConfig = { ...connection, id: "pg-ssh", name: "shared", db_type: "postgres", host: "private.local", ssh_enabled: true };
const usedConfigs: ConnectionConfig[] = [];
const scopedBackend: Backend = {
...backend,
loadConnections: async () => [connDirect, connSsh],
executeQuery: async (config, _sql) => {
usedConfigs.push(config);
return { columns: ["result"], rows: [{ result: "ok" }], row_count: 1 };
},
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
// connection_name with two same-name connections (one SSH-backed) → AMBIGUOUS_CONNECTION
const ambigResult = await (server as any)._registeredTools.dbx_execute_query.handler({
connection_name: "shared",
sql: "select 1",
});
assert.equal(ambigResult.isError, true);
assert.match(ambigResult.content[0].text, /AMBIGUOUS_CONNECTION:/);
assert.match(ambigResult.content[0].text, /pg-direct/);
assert.match(ambigResult.content[0].text, /pg-ssh/);
// connection_id routes to the SSH-backed instance and passes its config through
const result = await (server as any)._registeredTools.dbx_execute_query.handler({
connection_id: "pg-ssh",
sql: "select 1",
});
assert.equal(result.isError, undefined);
assert.equal(usedConfigs.length, 1);
assert.equal(usedConfigs[0].id, "pg-ssh");
assert.equal(usedConfigs[0].host, "private.local");
assert.equal(usedConfigs[0].ssh_enabled, true);
});
// --- Dialect-aware `#` comment handling ---
test("dbx_execute_query splits PG `#` operator statements correctly", async () => {
const executed: string[] = [];
const scopedBackend: Backend = {
...backend,
executeQuery: async (_config, sql) => {
executed.push(sql);
return { columns: ["value"], rows: [{ value: 1 }], row_count: 1 };
},
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
// On a postgres connection, `#` is an operator, not a comment.
// `SELECT 1 # 2; SELECT 3` should produce TWO executeQuery calls.
await (server as any)._registeredTools.dbx_execute_query.handler({
connection_name: "local",
sql: "SELECT 1 # 2; SELECT 3",
});
assert.deepEqual(executed, ["SELECT 1 # 2", "SELECT 3"]);
});
test("dbx_execute_query treats `#` as line comment on MySQL connections", async () => {
const mysqlConn: ConnectionConfig = { ...connection, id: "mysql-1", name: "mysql-local", db_type: "mysql" };
const executed: string[] = [];
const scopedBackend: Backend = {
...backend,
loadConnections: async () => [mysqlConn],
findConnection: async (name) => (name === "mysql-local" ? mysqlConn : undefined),
executeQuery: async (_config, sql) => {
executed.push(sql);
return { columns: ["value"], rows: [{ value: 1 }], row_count: 1 };
},
};
const server = createDbxMcpServer(scopedBackend, { isWebMode: true });
// On a mysql connection, `#` IS a line comment.
// The `;` in `SELECT 1;` splits the first statement. The `# comment\nSELECT 2`
// is a single statement — the `#` makes everything on that line a comment,
// and after the newline `SELECT 2` continues (no `;` to split).
await (server as any)._registeredTools.dbx_execute_query.handler({
connection_name: "mysql-local",
sql: "SELECT 1; # comment\nSELECT 2",
});
assert.deepEqual(executed, ["SELECT 1", "# comment\nSELECT 2"]);
});
test("dbx_execute_query blocks PG injection through `#` as comment in classification", async () => {
// `SELECT 1 # 2; DELETE FROM t` on a postgres connection: the `#` is an operator,
// so classification must see the DELETE and block it as a write in read-only mode.
const server = createDbxMcpServer(backend, { isWebMode: true });
const result = await (server as any)._registeredTools.dbx_execute_query.handler({
connection_name: "local",
sql: "SELECT 1 # 2; DELETE FROM t",
});
assert.equal(result.isError, true);
assert.match(result.content[0].text, /SQL_BLOCKED:/);
});

View File

@ -1,15 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "nodenext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"types": ["node"],
"outDir": "dist",
"rootDir": "src",
"declaration": true
},
"include": ["src"]
}

View File

@ -1,7 +1,8 @@
{
"name": "@dbx-app/mongo-shell",
"version": "0.4.36",
"description": "Pure MongoDB shell JSON/argument parsing shared by desktop and node-core",
"private": true,
"description": "MongoDB shell parsing helpers used by the DBX desktop editor",
"license": "Apache-2.0",
"type": "module",
"exports": {
@ -11,10 +12,6 @@
"default": "./dist/index.js"
}
},
"files": [
"dist",
"src"
],
"scripts": {
"build": "tsc",
"prepare": "tsc"

View File

@ -1,27 +0,0 @@
# DBX Node Core
Shared Node.js runtime utilities for DBX CLI and DBX MCP Server.
This package reads DBX Desktop connection storage, redacts connection summaries, builds schema context, applies SQL safety rules, and executes supported direct database queries.
## Supported Runtime
Requires Node.js 22.13.0 or newer.
## Direct Query Support
Direct execution currently supports:
- PostgreSQL and Redshift
- MySQL-compatible databases, including MySQL, Doris, and StarRocks
- SQLite
Other DBX connection types can be routed through DBX Desktop bridge integrations used by the CLI and MCP server.
## Public Modules
```ts
import { createBackend, loadConnections, getDbxDiagnostics, evaluateSqlSafety, buildSchemaContext } from "@dbx-app/node-core";
```
The package is intended as a shared implementation layer for official DBX Node packages. Applications should prefer `@dbx-app/cli` for terminal workflows and `@dbx-app/mcp-server` for MCP clients.

View File

@ -1,50 +0,0 @@
{
"name": "@dbx-app/node-core",
"version": "0.4.36",
"description": "Shared Node.js database and DBX connection utilities for DBX CLI and MCP server",
"license": "Apache-2.0",
"files": [
"dist"
],
"type": "module",
"exports": {
".": "./dist/index.js",
"./backend": "./dist/backend.js",
"./bridge": "./dist/bridge.js",
"./connections": "./dist/connections.js",
"./database": "./dist/database.js",
"./diagnostics": "./dist/diagnostics.js",
"./entrypoint": "./dist/entrypoint.js",
"./format": "./dist/format.js",
"./paths": "./dist/paths.js",
"./production-safety": "./dist/production-safety.js",
"./redis-command": "./dist/redis-command.js",
"./schema-context": "./dist/schema-context.js",
"./sql-diagnostics": "./dist/sql-diagnostics.js",
"./sql-risk": "./dist/sql-risk.js",
"./sql-safety": "./dist/sql-safety.js"
},
"scripts": {
"test": "vitest run --config vitest.config.ts",
"build": "pnpm --filter @dbx-app/mongo-shell build && tsc",
"prepublishOnly": "pnpm run build"
},
"dependencies": {
"@dbx-app/mongo-shell": "workspace:^",
"better-sqlite3": "^12.9.0",
"ioredis": "^5.11.1",
"keytar": "^7.9.0",
"mysql2": "^3.14.1",
"pg": "^8.16.0"
},
"devDependencies": {
"@types/better-sqlite3": "^7.6.13",
"@types/node": "^22.15.21",
"@types/pg": "^8.15.4",
"tsx": "^4.19.4",
"typescript": "^5.8.3"
},
"engines": {
"node": ">=22.13.0"
}
}

View File

@ -1,37 +0,0 @@
import { addConnection as desktopAddConnection, findConnection as desktopFindConnection, loadConnections as desktopLoadConnections, removeConnection as desktopRemoveConnection, removeConnectionById as desktopRemoveConnectionById } from "./connections.js";
import { closeDatabaseResources as desktopCloseDatabaseResources, describeTable as desktopDescribeTable, executeQuery as desktopExecuteQuery, executeRedisCommand as desktopExecuteRedisCommand, listTables as desktopListTables } from "./database.js";
import type { ConnectionConfig } from "./connections.js";
import type { ColumnInfo, QueryOptions, QueryResult, TableInfo } from "./database.js";
import type { RedisCommandOptions, RedisCommandResult } from "./redis-command.js";
export interface Backend {
loadConnections(): Promise<ConnectionConfig[]>;
findConnection(name: string): Promise<ConnectionConfig | undefined>;
addConnection(config: Omit<ConnectionConfig, "id">): Promise<ConnectionConfig>;
removeConnection(name: string): Promise<boolean>;
removeConnectionById?(id: string): Promise<boolean>;
listTables(config: ConnectionConfig, schema?: string): Promise<TableInfo[]>;
describeTable(config: ConnectionConfig, table: string, schema?: string): Promise<ColumnInfo[]>;
executeQuery(config: ConnectionConfig, sql: string, options?: QueryOptions): Promise<QueryResult>;
executeRedisCommand?(config: ConnectionConfig, db: number, command: string, options?: RedisCommandOptions): Promise<RedisCommandResult>;
close?(): Promise<void>;
}
export async function createBackend(env: NodeJS.ProcessEnv = process.env): Promise<Backend> {
if (env.DBX_WEB_URL) {
return await import("./web-backend.js");
}
return {
loadConnections: desktopLoadConnections,
findConnection: desktopFindConnection,
addConnection: desktopAddConnection,
removeConnection: desktopRemoveConnection,
removeConnectionById: desktopRemoveConnectionById,
listTables: desktopListTables,
describeTable: desktopDescribeTable,
executeQuery: desktopExecuteQuery,
executeRedisCommand: desktopExecuteRedisCommand,
close: desktopCloseDatabaseResources,
};
}

View File

@ -1,25 +0,0 @@
import { readFile } from "node:fs/promises";
import { bridgePortFilePath } from "./paths.js";
export async function getBridgeUrl(): Promise<string> {
const port = (await readFile(bridgePortFilePath(), "utf-8")).trim();
return `http://127.0.0.1:${port}`;
}
export async function postBridge(path: string, body: Record<string, unknown>): Promise<{ ok: true; text: string } | { ok: false; text: string }> {
try {
const bridgeUrl = await getBridgeUrl();
const res = await fetch(`${bridgeUrl}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return { ok: res.ok, text: res.ok ? "" : await res.text() };
} catch {
return { ok: false, text: "DBX is not running. Please start DBX first." };
}
}
export async function notifyReload(): Promise<void> {
await postBridge("/reload-connections", {});
}

View File

@ -1,376 +0,0 @@
import { join } from "node:path";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import Database from "better-sqlite3";
import { dbPath as defaultDbPath } from "./paths.js";
export interface ConnectionConfig {
id: string;
name: string;
db_type: string;
driver_profile?: string;
host: string;
port: number;
username: string;
password: string;
database?: string;
url_params?: string;
transport_layers?: TransportLayerConfig[];
keepalive_interval_secs?: number;
ssl: boolean;
ca_cert_path?: string;
client_cert_path?: string;
client_key_path?: string;
oracle_connection_type?: "service_name" | "sid";
redis_connection_mode?: "standalone" | "sentinel" | "cluster";
redis_sentinel_master?: string;
redis_sentinel_nodes?: string;
redis_sentinel_username?: string;
redis_sentinel_password?: string;
redis_sentinel_tls?: boolean;
redis_cluster_nodes?: string;
redis_key_separator?: string;
read_only?: boolean;
is_production?: boolean;
production_databases?: string[];
}
export type TransportLayerConfig = ({ type: "ssh" } & SshTunnelConfig) | ({ type: "proxy" } & ProxyTunnelConfig);
export interface SshTunnelConfig {
id: string;
name?: string;
enabled?: boolean;
host: string;
port: number;
user: string;
password?: string;
key_path?: string;
key_passphrase?: string;
connect_timeout_secs?: number;
expose_lan?: boolean;
use_ssh_agent?: boolean;
ssh_agent_sock_path?: string;
}
export interface ProxyTunnelConfig {
id: string;
name?: string;
enabled?: boolean;
proxy_type?: "socks5" | "http";
host: string;
port: number;
username?: string;
password?: string;
}
export interface ConnectionStoreOptions {
path?: string;
}
export interface ConnectionStoreDiagnostics {
dbPath: string;
dbPathExists: boolean;
connectionsTableExists: boolean;
connectionSecretsTableExists: boolean;
connectionRowCount: number;
loadConnectionsOk: boolean;
loadedConnectionCount: number;
loadConnectionsError?: string;
}
export class ConnectionStoreError extends Error {
readonly code = "CONNECTION_STORE_ERROR";
constructor(path: string, cause: unknown) {
const message = cause instanceof Error ? cause.message : String(cause);
super(`Failed to load DBX connections from ${path}: ${message}`);
this.name = "ConnectionStoreError";
}
}
export function canonicalizeConnection(config: ConnectionConfig): ConnectionConfig {
if (config.db_type === "mysql" && config.driver_profile?.toLowerCase() === "tdengine") {
return {
...config,
db_type: "tdengine",
driver_profile: "tdengine",
port: config.port === 0 || config.port === 6030 ? 6041 : config.port,
};
}
if (config.db_type === "tdengine") {
return {
...config,
driver_profile: "tdengine",
port: config.port || 6041,
};
}
return config;
}
function openDb(readonly = false, path = defaultDbPath()): Database.Database {
return new Database(path, { readonly });
}
function getSecret(db: Database.Database, connectionId: string, key: string): string {
const row = db.prepare("SELECT secret FROM connection_secrets WHERE connection_id = ? AND key = ?").get(connectionId, key) as { secret: string } | undefined;
return row?.secret ?? "";
}
function transportLayerSecretSegment(index: number, layer: TransportLayerConfig): string {
return layer.id?.trim() || String(index);
}
function transportLayerSshPasswordKey(index: number, layer: TransportLayerConfig): string {
return `transport_layers.${transportLayerSecretSegment(index, layer)}.ssh_password`;
}
function transportLayerSshKeyPassphraseKey(index: number, layer: TransportLayerConfig): string {
return `transport_layers.${transportLayerSecretSegment(index, layer)}.ssh_key_passphrase`;
}
function transportLayerProxyPasswordKey(index: number, layer: TransportLayerConfig): string {
return `transport_layers.${transportLayerSecretSegment(index, layer)}.proxy_password`;
}
type LegacyConnectionConfig = ConnectionConfig & {
ssh_enabled?: boolean;
ssh_host?: string;
ssh_port?: number;
ssh_user?: string;
ssh_password?: string;
ssh_key_path?: string;
ssh_key_passphrase?: string;
ssh_expose_lan?: boolean;
ssh_connect_timeout_secs?: number;
ssh_tunnels?: SshTunnelConfig[];
proxy_enabled?: boolean;
proxy_type?: "socks5" | "http";
proxy_host?: string;
proxy_port?: number;
proxy_username?: string;
proxy_password?: string;
};
function normalizeTransportLayers(config: LegacyConnectionConfig): TransportLayerConfig[] {
if (Array.isArray(config.transport_layers) && config.transport_layers.length > 0) return config.transport_layers;
const layers: TransportLayerConfig[] = [];
if (config.ssh_enabled && Array.isArray(config.ssh_tunnels) && config.ssh_tunnels.length > 0) {
layers.push(...config.ssh_tunnels.map((hop) => ({ type: "ssh" as const, ...hop })));
} else if (config.ssh_enabled && config.ssh_host) {
layers.push({
type: "ssh",
id: "legacy",
enabled: true,
host: config.ssh_host,
port: config.ssh_port || 22,
user: config.ssh_user || "",
password: config.ssh_password || "",
key_path: config.ssh_key_path || "",
key_passphrase: config.ssh_key_passphrase || "",
connect_timeout_secs: config.ssh_connect_timeout_secs || 5,
expose_lan: !!config.ssh_expose_lan,
use_ssh_agent: false,
});
}
if (config.proxy_enabled && config.proxy_host) {
layers.push({
type: "proxy",
id: "legacy-proxy",
enabled: true,
proxy_type: config.proxy_type || "socks5",
host: config.proxy_host,
port: config.proxy_port || 1080,
username: config.proxy_username || "",
password: config.proxy_password || "",
});
}
return layers;
}
function hydrateTransportLayerSecrets(db: Database.Database, config: ConnectionConfig, connectionId: string) {
config.transport_layers = normalizeTransportLayers(config as LegacyConnectionConfig);
config.transport_layers.forEach((layer, index) => {
if (layer.type === "ssh") {
layer.password ||= getSecret(db, connectionId, transportLayerSshPasswordKey(index, layer)) || (layer.id === "legacy" ? getSecret(db, connectionId, "ssh_password") : getSecret(db, connectionId, `ssh_tunnels.${layer.id || index}.password`));
layer.key_passphrase ||= getSecret(db, connectionId, transportLayerSshKeyPassphraseKey(index, layer)) || (layer.id === "legacy" ? getSecret(db, connectionId, "ssh_key_passphrase") : getSecret(db, connectionId, `ssh_tunnels.${layer.id || index}.key_passphrase`));
} else {
layer.password ||= getSecret(db, connectionId, transportLayerProxyPasswordKey(index, layer)) || (layer.id === "legacy-proxy" ? getSecret(db, connectionId, "proxy_password") : "");
}
});
}
export async function loadConnections(options: ConnectionStoreOptions = {}): Promise<ConnectionConfig[]> {
const path = options.path ?? defaultDbPath();
if (!existsSync(path)) return [];
let db: Database.Database | undefined;
try {
db = openDb(true, path);
const rows = db.prepare("SELECT id, config_json FROM connections").all() as { id: string; config_json: string }[];
const configs: ConnectionConfig[] = [];
for (const row of rows) {
const config: ConnectionConfig = canonicalizeConnection(JSON.parse(row.config_json));
config.id = row.id;
if (!config.password) config.password = getSecret(db, row.id, "password");
hydrateTransportLayerSecrets(db, config, row.id);
if (!config.redis_sentinel_password) {
config.redis_sentinel_password = getSecret(db, row.id, "redis_sentinel_password");
}
configs.push(config);
}
return configs;
} catch (error) {
throw new ConnectionStoreError(path, error);
} finally {
db?.close();
}
}
export async function inspectConnectionStore(options: ConnectionStoreOptions = {}): Promise<ConnectionStoreDiagnostics> {
const path = options.path ?? defaultDbPath();
const diagnostics: ConnectionStoreDiagnostics = {
dbPath: path,
dbPathExists: existsSync(path),
connectionsTableExists: false,
connectionSecretsTableExists: false,
connectionRowCount: 0,
loadConnectionsOk: true,
loadedConnectionCount: 0,
};
if (!diagnostics.dbPathExists) return diagnostics;
let db: Database.Database | undefined;
try {
db = openDb(true, path);
diagnostics.connectionsTableExists = tableExists(db, "connections");
diagnostics.connectionSecretsTableExists = tableExists(db, "connection_secrets");
if (diagnostics.connectionsTableExists) {
const row = db.prepare("SELECT COUNT(*) AS count FROM connections").get() as { count: number };
diagnostics.connectionRowCount = row.count;
}
} catch (error) {
diagnostics.loadConnectionsOk = false;
diagnostics.loadConnectionsError = error instanceof Error ? error.message : String(error);
return diagnostics;
} finally {
db?.close();
}
try {
const connections = await loadConnections({ path });
diagnostics.loadedConnectionCount = connections.length;
} catch (error) {
diagnostics.loadConnectionsOk = false;
diagnostics.loadConnectionsError = error instanceof Error ? error.message : String(error);
}
return diagnostics;
}
function tableExists(db: Database.Database, name: string): boolean {
const row = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name) as { "1": number } | undefined;
return !!row;
}
export async function findConnection(name: string): Promise<ConnectionConfig | undefined> {
const connections = await loadConnections();
return connections.find((c) => c.name.toLowerCase() === name.toLowerCase());
}
export async function findConnectionById(id: string): Promise<ConnectionConfig | undefined> {
const connections = await loadConnections();
return connections.find((c) => c.id === id);
}
export async function addConnection(config: Omit<ConnectionConfig, "id">): Promise<ConnectionConfig> {
const id = randomUUID();
const db = openDb();
const normalized = canonicalizeConnection({ ...config, id } as ConnectionConfig);
const full = {
id,
name: normalized.name,
db_type: normalized.db_type,
driver_profile: normalized.driver_profile ?? normalized.db_type,
driver_label: null,
url_params: normalized.url_params ?? "",
host: normalized.host,
port: normalized.port,
username: normalized.username,
password: "",
database: normalized.database ?? null,
color: null,
transport_layers: normalizeTransportLayers(normalized as LegacyConnectionConfig).map((layer) => {
if (layer.type === "ssh") return { ...layer, password: "", key_passphrase: "" };
return { ...layer, password: "" };
}),
ssl: normalized.ssl ?? false,
sysdba: false,
oracle_connection_type: normalized.oracle_connection_type ?? null,
connection_string: null,
redis_connection_mode: normalized.redis_connection_mode ?? "standalone",
redis_sentinel_master: normalized.redis_sentinel_master ?? "",
redis_sentinel_nodes: normalized.redis_sentinel_nodes ?? "",
redis_sentinel_username: normalized.redis_sentinel_username ?? "",
redis_sentinel_password: "",
redis_sentinel_tls: normalized.redis_sentinel_tls ?? false,
};
const configJson = JSON.stringify(full);
const insert = db.transaction(() => {
db.prepare("INSERT INTO connections (id, config_json) VALUES (?, ?)").run(id, configJson);
if (normalized.password) {
db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, "password", normalized.password);
}
normalizeTransportLayers(normalized as LegacyConnectionConfig).forEach((layer, index) => {
if (layer.type === "ssh") {
if (layer.password) {
db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, transportLayerSshPasswordKey(index, layer), layer.password);
}
if (layer.key_passphrase) {
db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, transportLayerSshKeyPassphraseKey(index, layer), layer.key_passphrase);
}
} else if (layer.password) {
db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, transportLayerProxyPasswordKey(index, layer), layer.password);
}
});
if (normalized.redis_sentinel_password) {
db.prepare("INSERT INTO connection_secrets (connection_id, key, secret) VALUES (?, ?, ?)").run(id, "redis_sentinel_password", normalized.redis_sentinel_password);
}
});
insert();
db.close();
return normalized;
}
export async function removeConnection(name: string): Promise<boolean> {
const connection = await findConnection(name);
if (!connection) return false;
const db = openDb();
const remove = db.transaction(() => {
db.prepare("DELETE FROM connections WHERE id = ?").run(connection.id);
db.prepare("DELETE FROM connection_secrets WHERE connection_id = ?").run(connection.id);
});
remove();
db.close();
return true;
}
export async function removeConnectionById(id: string): Promise<boolean> {
const db = openDb();
const remove = db.transaction(() => {
const result = db.prepare("DELETE FROM connections WHERE id = ?").run(id);
db.prepare("DELETE FROM connection_secrets WHERE connection_id = ?").run(id);
return result.changes > 0;
});
const deleted = remove();
db.close();
return deleted;
}

File diff suppressed because it is too large Load Diff

View File

@ -1,132 +0,0 @@
import { access, readFile } from "node:fs/promises";
import { bridgePortFilePath, dbPath, appDataDir } from "./paths.js";
import { inspectConnectionStore } from "./connections.js";
export const DIRECT_QUERY_TYPES = ["postgres", "redshift", "mysql", "doris", "starrocks", "manticoresearch", "sqlite", "rqlite", "kwdb", "questdb"] as const;
export type DirectQueryType = (typeof DIRECT_QUERY_TYPES)[number];
const DIRECT_QUERY_TYPE_SET = new Set<string>(DIRECT_QUERY_TYPES);
export function isDirectQueryType(dbType: string): dbType is DirectQueryType {
return DIRECT_QUERY_TYPE_SET.has(dbType);
}
export const BRIDGE_REQUIRED_TYPES = [
"cloudflare-d1",
"redis",
"mongodb",
"duckdb",
"clickhouse",
"sqlserver",
"oracle",
"elasticsearch",
"qdrant",
"milvus",
"weaviate",
"chromadb",
"etcd",
"dameng",
"kingbase",
"highgo",
"vastbase",
"goldendb",
"databend",
"gaussdb",
"yashandb",
"databricks",
"saphana",
"teradata",
"vertica",
"firebird",
"exasol",
"opengauss",
"oceanbase-oracle",
"gbase",
"tdengine",
"iotdb",
"h2",
"snowflake",
"trino",
"prestosql",
"hive",
"spark",
"db2",
"informix",
"iris",
"neo4j",
"cassandra",
"bigquery",
"kylin",
"sundb",
"oscar",
"xugu",
"jdbc",
"access",
"influxdb",
"zookeeper",
] as const;
export interface DbxDiagnostics {
appDataDir: string;
dbPath: string;
dbPathExists: boolean;
connectionsTableExists: boolean;
connectionSecretsTableExists?: boolean;
connectionRowCount: number;
loadConnectionsOk: boolean;
loadedConnectionCount: number;
loadConnectionsError?: string;
loadConnectionsHint?: string;
bridgePortFile: string;
bridgePortFileExists: boolean;
bridgeUrl?: string;
directQueryTypes: string[];
bridgeRequiredTypes: string[];
}
export async function getDbxDiagnostics(): Promise<DbxDiagnostics> {
const portFile = bridgePortFilePath();
const bridgePortFileExists = await exists(portFile);
let bridgeUrl: string | undefined;
if (bridgePortFileExists) {
const port = (await readFile(portFile, "utf-8")).trim();
if (port) bridgeUrl = `http://127.0.0.1:${port}`;
}
const path = dbPath();
const connectionStore = await inspectConnectionStore({ path });
return {
appDataDir: appDataDir(),
dbPath: path,
dbPathExists: connectionStore.dbPathExists,
connectionsTableExists: connectionStore.connectionsTableExists,
connectionSecretsTableExists: connectionStore.connectionSecretsTableExists,
connectionRowCount: connectionStore.connectionRowCount,
loadConnectionsOk: connectionStore.loadConnectionsOk,
loadedConnectionCount: connectionStore.loadedConnectionCount,
loadConnectionsError: connectionStore.loadConnectionsError,
loadConnectionsHint: connectionStore.loadConnectionsError ? connectionStoreHint(connectionStore.loadConnectionsError) : undefined,
bridgePortFile: portFile,
bridgePortFileExists,
bridgeUrl,
directQueryTypes: [...DIRECT_QUERY_TYPES],
bridgeRequiredTypes: [...BRIDGE_REQUIRED_TYPES],
};
}
function connectionStoreHint(message: string): string | undefined {
if (/NODE_MODULE_VERSION|compiled against a different Node\.js version/i.test(message)) {
return "Rebuild DBX CLI native dependencies with your active Node.js: pnpm rebuild better-sqlite3 keytar --pending, or reinstall the package with the same Node.js version you use to run dbx.";
}
return undefined;
}
async function exists(path: string): Promise<boolean> {
try {
await access(path);
return true;
} catch {
return false;
}
}

View File

@ -1,21 +0,0 @@
import { realpathSync } from "node:fs";
import { normalize, resolve } from "node:path";
import { fileURLToPath } from "node:url";
export function isMainModule(moduleUrl: string, argvPath: string | undefined): boolean {
if (!argvPath) return false;
return normalizeEntryPath(fileURLToPath(moduleUrl)) === normalizeEntryPath(argvPath);
}
function normalizeEntryPath(path: string): string {
const normalized = normalize(realpathIfPossible(resolve(path)));
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
}
function realpathIfPossible(path: string): string {
try {
return realpathSync.native(path);
} catch {
return path;
}
}

View File

@ -1,13 +0,0 @@
export function mdTable(headers: string[], rows: string[][]): string {
const widths = headers.map((h, i) => Math.max(h.length, ...rows.map((r) => (r[i] || "").length), 3));
const header = `| ${headers.map((h, i) => h.padEnd(widths[i])).join(" | ")} |`;
const sep = `| ${widths.map((w) => "-".repeat(w)).join(" | ")} |`;
const body = rows.map((r) => `| ${r.map((c, i) => (c || "").padEnd(widths[i])).join(" | ")} |`).join("\n");
return body ? `${header}\n${sep}\n${body}` : `${header}\n${sep}`;
}
export function formatCell(value: unknown): string {
if (value === null || value === undefined) return "NULL";
if (typeof value === "object") return JSON.stringify(value);
return String(value);
}

View File

@ -1,14 +0,0 @@
export * from "./backend.js";
export * from "./bridge.js";
export * from "./connections.js";
export * from "./database.js";
export * from "./diagnostics.js";
export * from "./entrypoint.js";
export * from "./format.js";
export * from "./paths.js";
export * from "./production-safety.js";
export * from "./redis-command.js";
export * from "./schema-context.js";
export * from "./sql-diagnostics.js";
export * from "./sql-risk.js";
export * from "./sql-safety.js";

View File

@ -1,35 +0,0 @@
import { homedir, platform } from "node:os";
import { join, posix, win32 } from "node:path";
export function appDataDir(): string {
// 支持 DBX_DATA_DIR 环境变量(与 Rust 侧 data_dir.rs 保持一致)
return appDataDirFromInputs({
platform: platform(),
home: homedir(),
appData: process.env.APPDATA,
envDataDir: process.env.DBX_DATA_DIR,
});
}
export function appDataDirFromInputs(options: { platform: NodeJS.Platform; home: string; appData?: string; envDataDir?: string }): string {
if (options.envDataDir && options.envDataDir.trim() !== "") {
return options.envDataDir;
}
switch (options.platform) {
case "darwin":
return posix.join(options.home, "Library", "Application Support", "com.dbx.app");
case "win32":
return win32.join(options.appData || win32.join(options.home, "AppData", "Roaming"), "com.dbx.app");
default:
return posix.join(options.home, ".local", "share", "com.dbx.app");
}
}
export function dbPath(): string {
return join(appDataDir(), "dbx.db");
}
export function bridgePortFilePath(): string {
return join(appDataDir(), "mcp-bridge-port");
}

View File

@ -1,315 +0,0 @@
import type { ConnectionConfig } from "./connections.js";
import { classifySqlRisk, isSqlRiskMutation, supportsHashLineComments } from "./sql-risk.js";
export interface ProductionSqlAssessment {
active: boolean;
isMutation: boolean;
databases: string[];
}
const IDENTIFIER_PATTERN = String.raw`[A-Za-z0-9_@$#-]*[A-Za-z_@$#][A-Za-z0-9_@$#-]*`;
const TARGET_NAME_PATTERN = String.raw`${IDENTIFIER_PATTERN}(?:\s*\.\s*(?:\*|${IDENTIFIER_PATTERN})){0,2}`;
const QUALIFIED_NAME_PATTERN = String.raw`${IDENTIFIER_PATTERN}\s*\.\s*(?:\*|${IDENTIFIER_PATTERN})(?:\s*\.\s*(?:\*|${IDENTIFIER_PATTERN}))?`;
const USE_RE = new RegExp(String.raw`^\s*USE\s+(${IDENTIFIER_PATTERN})`, "i");
const DML_TARGET_RE = new RegExp(String.raw`\b(?:FROM|JOIN|UPDATE|INTO|REFERENCES)\s+(${TARGET_NAME_PATTERN})`, "gi");
const DDL_OBJECT_TARGET_RE = new RegExp(String.raw`\b(?:CREATE|ALTER|DROP)\s+(?:OR\s+REPLACE\s+)?(?:TABLE|VIEW|MATERIALIZED\s+VIEW|INDEX|SEQUENCE|FUNCTION|PROCEDURE|ROUTINE|TRIGGER|EVENT|TYPE|SYNONYM)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?(?:ONLY\s+)?(${TARGET_NAME_PATTERN})`, "gi");
const INDEX_ON_TARGET_RE = new RegExp(String.raw`\b(?:CREATE|ALTER|DROP)\s+(?:UNIQUE\s+)?INDEX\b[\s\S]*?\bON\s+(${TARGET_NAME_PATTERN})`, "gi");
const DATABASE_TARGET_RE = new RegExp(String.raw`\b(?:CREATE|ALTER|DROP)\s+(DATABASE|SCHEMA|CATALOG)\s+(?:IF\s+(?:NOT\s+)?EXISTS\s+)?(${IDENTIFIER_PATTERN})`, "gi");
const COPY_TARGET_RE = new RegExp(String.raw`^\s*COPY\s+(${TARGET_NAME_PATTERN})\s+FROM\b`, "i");
const TRUNCATE_TARGET_RE = new RegExp(String.raw`\bTRUNCATE\s+(?:TABLE\s+)?(${TARGET_NAME_PATTERN})`, "gi");
const RENAME_TABLE_TARGET_RE = new RegExp(String.raw`\bRENAME\s+TABLE\s+(${TARGET_NAME_PATTERN})\s+TO\s+(${TARGET_NAME_PATTERN})`, "gi");
const MAINTENANCE_TABLE_TARGET_RE = new RegExp(String.raw`\b(?:ANALYZE|OPTIMIZE|REPAIR|CHECK)\s+(?:NO_WRITE_TO_BINLOG\s+|LOCAL\s+)?TABLE\s+(${TARGET_NAME_PATTERN})`, "gi");
const COMMENT_TARGET_RE = new RegExp(String.raw`\bCOMMENT\s+ON\s+(?:TABLE|VIEW|COLUMN|INDEX|SEQUENCE|FUNCTION|PROCEDURE|TYPE)\s+(${TARGET_NAME_PATTERN})`, "gi");
const ROUTINE_CALL_TARGET_RE = new RegExp(String.raw`\b(?:CALL|EXEC|EXECUTE)\s+(${QUALIFIED_NAME_PATTERN})`, "gi");
const PRIVILEGE_TARGET_RE = new RegExp(String.raw`\b(?:GRANT|REVOKE|DENY)\b[\s\S]*?\bON\s+(?:(?:TABLE|SEQUENCE|FUNCTION|PROCEDURE|ROUTINE|OBJECT)\s+|OBJECT\s*::\s*)?(${QUALIFIED_NAME_PATTERN})`, "gi");
const PRIVILEGE_DATABASE_TARGET_RE = new RegExp(String.raw`\b(?:GRANT|REVOKE|DENY)\b[\s\S]*?\bON\s+(?:DATABASE|CATALOG)(?:::|\s+)\s*(${IDENTIFIER_PATTERN})`, "gi");
const GLOBAL_PRIVILEGE_TARGET_RE = /\b(?:GRANT|REVOKE|DENY)\b[\s\S]*?\bON\s+\*\s*\.\s*\*/i;
const GLOBAL_DDL_TARGET_RE = /^\s*(?:CREATE|ALTER|DROP)\s+(?:USER|ROLE|LOGIN|SERVER|TABLESPACE|RESOURCE|PROFILE|ACCOUNT)\b/i;
const MULTI_TARGET_MUTATION_RE = /^\s*(?:DROP\s+(?:TEMPORARY\s+)?TABLE\b[\s\S]*,|RENAME\s+TABLE\b[\s\S]*,)/i;
const THREE_PART_DATABASE_QUALIFIER_TYPES = new Set(["sqlserver", "snowflake", "trino", "prestosql", "databricks", "bigquery"]);
const TRANSACTION_KEYWORDS = new Set(["begin", "start", "commit", "rollback", "abort", "savepoint", "release"]);
const SCHEMA_FIRST_QUALIFIER_TYPES = new Set([
"postgres",
"redshift",
"gaussdb",
"kwdb",
"opengauss",
"kingbase",
"highgo",
"vastbase",
"yashandb",
"oracle",
"oceanbase-oracle",
"dameng",
"firebird",
"exasol",
"teradata",
"vertica",
"db2",
"informix",
"h2",
"iris",
"xugu",
"oscar",
"gbase",
"saphana",
"sqlserver",
"snowflake",
"trino",
"prestosql",
"databricks",
"bigquery",
]);
interface ReferencedDatabaseAssessment {
databases: string[];
uncertain: boolean;
}
interface SqlTargetSafetyText {
text: string;
quotedIdentifiers: Map<string, string>;
}
/** Normalizes quoted database names before production scope comparison. */
export function normalizeProductionDatabase(value: string | undefined | null): string {
return String(value ?? "")
.trim()
.replace(/^[`"[]|[`"\]]$/g, "")
.toLowerCase();
}
export function isProductionDatabase(config: ConnectionConfig | undefined, database?: string): boolean {
if (!config) return false;
if (config.is_production) return true;
const selected = normalizeProductionDatabase(database);
return !!selected && (config.production_databases ?? []).some((name) => normalizeProductionDatabase(name) === selected);
}
/**
* Finds writes that target a marked production database, including a MySQL
* USE switch or a qualified database.table reference in a statement batch.
*/
export function assessProductionSql(sql: string, config: ConnectionConfig | undefined, activeDatabase?: string): ProductionSqlAssessment {
const targetText = sqlTargetSafetyText(sql);
const statements = splitTargetStatements(targetText.text);
const hashLineComments = supportsHashLineComments(config?.db_type);
const isMutation = isSqlRiskMutation(classifySqlRisk(sql, { hashLineComments }).risk);
if (!isMutation || !config) return { active: isProductionDatabase(config, activeDatabase), isMutation, databases: [] };
if (config.is_production) return { active: true, isMutation, databases: [] };
if (isProductionDatabase(config, activeDatabase)) return { active: true, isMutation, databases: activeDatabase ? [activeDatabase] : [] };
const marked = new Set((config.production_databases ?? []).map(normalizeProductionDatabase).filter(Boolean));
if (!marked.size) return { active: false, isMutation, databases: [] };
const targets = referencedDatabases(statements, config.db_type, hashLineComments, activeDatabase, targetText.quotedIdentifiers);
const databases = targets.databases.filter((database) => marked.has(normalizeProductionDatabase(database)));
return { active: databases.length > 0 || targets.uncertain, isMutation, databases: databases.length > 0 ? databases : targets.uncertain ? [...marked] : [] };
}
function referencedDatabases(statements: string[], dbType: string, hashLineComments: boolean, activeDatabase: string | undefined, quotedIdentifiers: Map<string, string>): ReferencedDatabaseAssessment {
const databases = new Set<string>();
let uncertain = false;
let useDatabase = "";
const normalizedActiveDatabase = normalizeProductionDatabase(activeDatabase);
for (const statement of statements) {
const statementDatabases = new Set<string>();
const statementAssessment = classifySqlRisk(statement, { hashLineComments });
const statementIsMutation = isSqlRiskMutation(statementAssessment.risk);
const useMatch = statement.match(USE_RE);
if (useMatch?.[1]) {
useDatabase = normalizeTargetDatabase(useMatch[1], quotedIdentifiers);
continue;
}
if (!statementIsMutation) continue;
const currentDatabase = useDatabase || normalizedActiveDatabase;
collectQualifiedTargetDatabases(statement, dbType, quotedIdentifiers, currentDatabase, statementDatabases, DML_TARGET_RE, DDL_OBJECT_TARGET_RE, INDEX_ON_TARGET_RE, TRUNCATE_TARGET_RE, MAINTENANCE_TABLE_TARGET_RE, COMMENT_TARGET_RE, ROUTINE_CALL_TARGET_RE, PRIVILEGE_TARGET_RE);
collectQualifiedTargetDatabaseGroups(statement, dbType, quotedIdentifiers, currentDatabase, statementDatabases, RENAME_TABLE_TARGET_RE, [1, 2]);
for (const match of statement.matchAll(DATABASE_TARGET_RE)) {
const database = databaseTargetKindMeansDatabase(match[1], dbType) ? normalizeTargetDatabase(match[2], quotedIdentifiers) : "";
if (database) statementDatabases.add(database);
}
for (const match of statement.matchAll(PRIVILEGE_DATABASE_TARGET_RE)) {
const database = normalizeTargetDatabase(match[1], quotedIdentifiers);
if (database) statementDatabases.add(database);
}
const copyTarget = statement.match(COPY_TARGET_RE);
if (copyTarget?.[1]) {
const database = databaseFromQualifiedName(copyTarget[1], dbType, quotedIdentifiers, currentDatabase);
if (database) statementDatabases.add(database);
}
for (const database of statementDatabases) databases.add(database);
// The target regexes intentionally extract one object at a time. Until all
// list forms are parsed, never let a resolved first target disable fallback.
uncertain = uncertain || GLOBAL_PRIVILEGE_TARGET_RE.test(statement) || MULTI_TARGET_MUTATION_RE.test(statement) || isAmbiguousProductionTargetStatement(statement, statementAssessment, statementDatabases.size > 0);
}
return { databases: [...databases], uncertain };
}
function collectQualifiedTargetDatabases(statement: string, dbType: string, quotedIdentifiers: Map<string, string>, currentDatabase: string, databases: Set<string>, ...patterns: RegExp[]): void {
for (const pattern of patterns) {
collectQualifiedTargetDatabaseGroups(statement, dbType, quotedIdentifiers, currentDatabase, databases, pattern, [1]);
}
}
function collectQualifiedTargetDatabaseGroups(statement: string, dbType: string, quotedIdentifiers: Map<string, string>, currentDatabase: string, databases: Set<string>, pattern: RegExp, captureIndexes: number[]): void {
pattern.lastIndex = 0;
for (const match of statement.matchAll(pattern)) {
for (const captureIndex of captureIndexes) {
const database = databaseFromQualifiedName(match[captureIndex], dbType, quotedIdentifiers, currentDatabase);
if (database) databases.add(database);
}
}
}
function databaseFromQualifiedName(qualifiedName: string | undefined, dbType: string, quotedIdentifiers: Map<string, string>, currentDatabase: string): string {
const parts = String(qualifiedName ?? "")
.split(".")
.map((part) => normalizeTargetDatabase(part, quotedIdentifiers))
.filter(Boolean);
if (parts.length < 2) return currentDatabase;
if (qualifiedFirstPartIsDatabase(dbType, parts.length)) return parts[0] ?? "";
return currentDatabase;
}
function normalizeTargetDatabase(value: string | undefined, quotedIdentifiers: Map<string, string>): string {
const normalized = normalizeProductionDatabase(value);
const quoted = quotedIdentifiers.get(normalized);
return quoted === undefined ? normalized : normalizeProductionDatabase(quoted);
}
function qualifiedFirstPartIsDatabase(dbType: string, partCount: number): boolean {
const normalizedType = dbType.toLowerCase();
if (partCount >= 3 && THREE_PART_DATABASE_QUALIFIER_TYPES.has(normalizedType)) return true;
if (SCHEMA_FIRST_QUALIFIER_TYPES.has(normalizedType)) return false;
return partCount >= 2;
}
function databaseTargetKindMeansDatabase(kind: string | undefined, dbType: string): boolean {
const normalizedKind = String(kind ?? "").toLowerCase();
if (normalizedKind === "database" || normalizedKind === "catalog") return true;
if (normalizedKind !== "schema") return false;
return !SCHEMA_FIRST_QUALIFIER_TYPES.has(dbType.toLowerCase());
}
function isAmbiguousProductionTargetStatement(statement: string, assessment: ReturnType<typeof classifySqlRisk>, hasResolvedTarget: boolean): boolean {
if (!isSqlRiskMutation(assessment.risk)) return false;
if (assessment.risk === "transaction") return false;
const firstKeyword = assessment.firstKeyword;
if (firstKeyword && TRANSACTION_KEYWORDS.has(firstKeyword)) return false;
return GLOBAL_DDL_TARGET_RE.test(statement) || !hasResolvedTarget;
}
function splitTargetStatements(sql: string): string[] {
return sql
.split(";")
.map((statement) => statement.trim())
.filter(Boolean);
}
function sqlTargetSafetyText(sql: string, quotedIdentifiers = new Map<string, string>()): SqlTargetSafetyText {
let output = "";
let index = 0;
while (index < sql.length) {
const char = sql[index] ?? "";
const next = sql[index + 1] ?? "";
if (char === "-" && next === "-") {
index += 2;
while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1;
output += " ";
continue;
}
if (char === "#") {
index += 1;
while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1;
output += " ";
continue;
}
if (char === "/" && next === "*") {
const close = sql.indexOf("*/", index + 2);
if (close < 0) return { text: output, quotedIdentifiers };
const executablePrefixLength = mysqlExecutableCommentPrefixLength(sql, index);
if (executablePrefixLength > 0) {
const bodyStart = skipExecutableCommentVersion(sql, index + executablePrefixLength);
output += ` ${sqlTargetSafetyText(sql.slice(bodyStart, close), quotedIdentifiers).text} `;
} else {
output += " ";
}
index = close + 2;
continue;
}
const dollarQuote = dollarQuoteTagAt(sql, index);
if (dollarQuote) {
const close = sql.indexOf(dollarQuote, index + dollarQuote.length);
index = close < 0 ? sql.length : close + dollarQuote.length;
output += " ";
continue;
}
if (char === "'") {
index = readQuotedEnd(sql, index, "'", "'");
output += " ";
continue;
}
if (char === '"' || char === "`" || char === "[") {
const close = char === "[" ? "]" : char;
const end = readQuotedEnd(sql, index, char, close);
const identifier = unquoteIdentifier(sql.slice(index, end), char, close).replace(/[;]/g, " ");
const token = `__dbxq${quotedIdentifiers.size}__`;
quotedIdentifiers.set(token.toLowerCase(), identifier);
output += ` ${token} `;
index = end;
continue;
}
output += char;
index += 1;
}
return { text: output, quotedIdentifiers };
}
function mysqlExecutableCommentPrefixLength(sql: string, index: number): number {
if (sql[index] !== "/" || sql[index + 1] !== "*") return 0;
if (sql[index + 2] === "!") return 3;
if (sql[index + 2] === "M" && sql[index + 3] === "!") return 4;
return 0;
}
function skipExecutableCommentVersion(sql: string, index: number): number {
let cursor = index;
while (cursor < sql.length && /[0-9\s]/.test(sql[cursor] ?? "")) cursor += 1;
return cursor;
}
function dollarQuoteTagAt(sql: string, index: number): string | undefined {
return sql.slice(index).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/)?.[0];
}
function readQuotedEnd(sql: string, start: number, open: string, close: string): number {
let index = start + open.length;
while (index < sql.length) {
if (sql[index] === "\\" && (open === "'" || open === '"')) {
index += 2;
continue;
}
if (sql.startsWith(close, index)) {
if (sql.startsWith(close + close, index)) {
index += close.length * 2;
continue;
}
return index + close.length;
}
index += 1;
}
return sql.length;
}
function unquoteIdentifier(value: string, open: string, close: string): string {
if (!value.startsWith(open) || !value.endsWith(close)) return value;
return value.slice(open.length, value.length - close.length).replaceAll(close + close, close);
}
/** MCP receives Mongo shell text rather than SQL, so use a conservative write detector. */
export function isLikelyMongoMutation(command: string): boolean {
return /\.(?:insert(?:One|Many)?|update(?:One|Many)?|replaceOne|delete(?:One|Many)?|findOneAnd(?:Update|Replace|Delete)|drop(?:Index|Indexes)?|renameCollection|createIndex)\s*\(|\bdb\.createCollection\s*\(/i.test(command);
}

View File

@ -1,213 +0,0 @@
import type { SqlSafetyOptions } from "./sql-safety.js";
export type RedisCommandSafety = "allowed" | "write" | "confirm" | "blocked";
export interface RedisCommandResult {
command: string;
safety: RedisCommandSafety;
value: unknown;
}
export interface RedisCommandOptions {
skipSafetyCheck?: boolean;
timeoutMs?: number;
}
export interface RedisCommandSafetyDecision {
allowed: boolean;
command?: string;
safety?: RedisCommandSafety;
reason?: string;
skipSafetyCheck?: boolean;
}
const BLOCKED_REDIS_COMMANDS = new Set(["KEYS", "FLUSHALL", "SHUTDOWN", "CONFIG", "SAVE", "BGSAVE", "SLAVEOF", "REPLICAOF", "MIGRATE", "MODULE", "SCRIPT", "EVAL", "EVALSHA"]);
const CONFIRM_REDIS_COMMANDS = new Set([
"DEL",
"UNLINK",
"EXPIRE",
"EXPIREAT",
"PEXPIRE",
"PEXPIREAT",
"RENAME",
"RENAMENX",
"GETDEL",
"HDEL",
"LPOP",
"RPOP",
"LREM",
"LTRIM",
"SPOP",
"SREM",
"ZREM",
"ZPOPMAX",
"ZPOPMIN",
"ZMPOP",
"ZREMRANGEBYLEX",
"ZREMRANGEBYRANK",
"ZREMRANGEBYSCORE",
"XDEL",
"XTRIM",
"MOVE",
"SORT",
"SDIFFSTORE",
"SINTERSTORE",
"SUNIONSTORE",
"ZDIFFSTORE",
"ZINTERSTORE",
"ZRANGESTORE",
"ZUNIONSTORE",
"PFMERGE",
"GEOSEARCHSTORE",
"FLUSHDB",
]);
const WRITE_REDIS_COMMANDS = new Set([
"APPEND",
"BITFIELD",
"BITOP",
"COPY",
"DECR",
"DECRBY",
"GEOADD",
"GEORADIUS",
"GEORADIUSBYMEMBER",
"GETSET",
"INCR",
"INCRBY",
"INCRBYFLOAT",
"SET",
"SETEX",
"PSETEX",
"SETNX",
"SETRANGE",
"MSET",
"MSETNX",
"PERSIST",
"HSET",
"HMSET",
"HINCRBY",
"HINCRBYFLOAT",
"HSETNX",
"LINSERT",
"LSET",
"LMOVE",
"LPUSH",
"LPUSHX",
"PFADD",
"RPUSH",
"RPUSHX",
"RESTORE",
"SADD",
"ZADD",
"ZINCRBY",
"SETBIT",
"XADD",
"XACK",
"XAUTOCLAIM",
"XCLAIM",
"XSETID",
]);
export function firstRedisCommandToken(commandText: string): string | undefined {
try {
return parseRedisCommandArgv(commandText)[0]?.toUpperCase();
} catch {
const token = commandText.trim().match(/^\S+/)?.[0]?.toUpperCase();
return token || undefined;
}
}
export function classifyRedisCommand(commandText: string): RedisCommandSafety {
const command = firstRedisCommandToken(commandText);
if (!command) return "blocked";
if (BLOCKED_REDIS_COMMANDS.has(command)) return "blocked";
if (CONFIRM_REDIS_COMMANDS.has(command)) return "confirm";
if (WRITE_REDIS_COMMANDS.has(command)) return "write";
return "allowed";
}
export function evaluateRedisCommandSafety(commandText: string, options: SqlSafetyOptions = {}): RedisCommandSafetyDecision {
const command = firstRedisCommandToken(commandText);
if (!command) {
return { allowed: false, reason: "Redis command is empty." };
}
const safety = classifyRedisCommand(command);
if (safety === "blocked" && !options.allowDangerous) {
return {
allowed: false,
command,
safety,
reason: `Dangerous Redis command "${command}" is blocked. Set DBX_MCP_ALLOW_DANGEROUS_SQL=1 to allow it.`,
};
}
if (safety !== "allowed" && !options.allowWrites) {
return {
allowed: false,
command,
safety,
reason: "MCP Redis command execution is read-only for this session. Set DBX_MCP_ALLOW_WRITES=1 to allow write or dangerous commands.",
};
}
return {
allowed: true,
command,
safety,
skipSafetyCheck: safety === "blocked" && options.allowDangerous === true,
};
}
export function parseRedisCommandArgv(commandText: string): string[] {
const trimmed = commandText.trimEnd().replace(/;+$/, "");
const argv: string[] = [];
let current = "";
let quote: '"' | "'" | undefined;
let escaping = false;
for (const ch of trimmed) {
if (escaping) {
if (ch === "n") current += "\n";
else if (ch === "r") current += "\r";
else if (ch === "t") current += "\t";
else current += ch;
escaping = false;
continue;
}
if (ch === "\\") {
escaping = true;
continue;
}
if (quote) {
if (ch === quote) quote = undefined;
else current += ch;
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (/\s/.test(ch)) {
if (current) {
argv.push(current);
current = "";
}
continue;
}
current += ch;
}
if (escaping) current += "\\";
if (quote) throw new Error("Redis command has an unterminated quote");
if (current) argv.push(current);
if (argv.length === 0) throw new Error("Redis command is empty");
return argv;
}

View File

@ -1,66 +0,0 @@
import type { ConnectionConfig } from "./connections.js";
import type { ColumnInfo, TableInfo } from "./database.js";
export interface SchemaContextBackend {
listTables(config: ConnectionConfig, schema?: string): Promise<TableInfo[]>;
describeTable(config: ConnectionConfig, table: string, schema?: string): Promise<ColumnInfo[]>;
}
export interface SchemaContextOptions {
schema?: string;
tables?: string[];
maxTables?: number;
}
export interface SchemaContextTable {
name: string;
type: string;
columns: ColumnInfo[];
}
export interface SchemaContext {
connection: string;
database: string;
schema: string;
truncated: boolean;
tables: SchemaContextTable[];
}
const DEFAULT_MAX_TABLES = 8;
export async function buildSchemaContext(backend: SchemaContextBackend, config: ConnectionConfig, options: SchemaContextOptions = {}): Promise<SchemaContext> {
const maxTables = Math.max(1, Math.min(options.maxTables ?? DEFAULT_MAX_TABLES, 20));
const availableTables = await backend.listTables(config, options.schema);
const requested = new Set((options.tables ?? []).map((table) => table.toLowerCase()));
const selected = requested.size ? availableTables.filter((table) => requested.has(table.name.toLowerCase())) : availableTables.slice(0, maxTables);
const limited = selected.slice(0, maxTables);
const tables = await Promise.all(
limited.map(async (table) => ({
name: table.name,
type: table.type,
columns: await backend.describeTable(config, table.name, options.schema),
})),
);
return {
connection: config.name,
database: config.database || "",
schema: options.schema || "",
truncated: selected.length > limited.length || (!requested.size && availableTables.length > limited.length),
tables,
};
}
export function formatSchemaContext(context: SchemaContext): string {
const header = [`Connection: ${context.connection}`, context.database ? `Database: ${context.database}` : "", context.schema ? `Schema: ${context.schema}` : ""].filter(Boolean);
const sections = context.tables.map((table) => {
const lines = table.columns.map((column) => {
const parts = [column.name, column.data_type, column.is_nullable ? "NULL" : "NOT NULL", column.is_primary_key ? "PK" : ""].filter(Boolean);
return `- ${parts.join(" ")}${column.comment ? ` -- ${column.comment}` : ""}`;
});
return [`## ${table.name}`, `Type: ${table.type}`, ...lines].join("\n");
});
const suffix = context.truncated ? "\n\nNote: table list was truncated; request specific table names for more context." : "";
return `${header.join("\n")}\n\n${sections.join("\n\n")}${suffix}`;
}

View File

@ -1,130 +0,0 @@
const DEFAULT_SQL_DIAGNOSTIC_MAX_CHARS = 512;
const SENSITIVE_NAME_RE = /(?:password|passwd|pwd|secret|token|api[_-]?key|access[_-]?key|private[_-]?key|credential|authorization|bearer)/i;
function boundedInput(sql: string, maxChars: number): [string, boolean] {
if (maxChars <= 0) return ["", sql.length > 0];
let end = 0;
let chars = 0;
for (const character of sql) {
if (chars === maxChars) return [sql.slice(0, end), true];
end += character.length;
chars += 1;
}
return [sql, false];
}
function truncateForDiagnostic(value: string, maxChars: number, inputTruncated: boolean): string {
if (value.length > maxChars) return `${value.slice(0, maxChars)}…[truncated]`;
return inputTruncated ? `${value}…[truncated]` : value;
}
function redactSqlLiterals(sql: string): string {
let result = "";
let i = 0;
while (i < sql.length) {
const ch = sql[i];
const next = sql[i + 1];
if (ch === "'" || ch === '"' || ch === "`") {
const quote = ch;
result += `${quote}[REDACTED]${quote}`;
i += 1;
while (i < sql.length) {
const current = sql[i];
if (current === quote) {
if (sql[i + 1] === quote) {
i += 2;
continue;
}
i += 1;
break;
}
if (current === "\\" && quote !== "`") {
i += 2;
} else {
i += 1;
}
}
continue;
}
if (ch === "$") {
const j = i + 1;
if (j >= sql.length) {
result += "$";
i += 1;
continue;
}
if (sql[j] === "$") {
// $$...$$ empty-tag dollar-quoted string
result += "$$[REDACTED]$$";
i += 2;
while (i + 1 < sql.length && !(sql[i] === "$" && sql[i + 1] === "$")) {
i += 1;
}
if (i + 1 < sql.length) {
i += 2;
}
continue;
}
// $tag$...$tag$ dollar-quoted string — tag must be ASCII alphanumerics + underscore only
const TAG_CHAR = /^[A-Za-z0-9_]$/;
let tagEnd = j;
while (tagEnd < sql.length && TAG_CHAR.test(sql[tagEnd])) {
tagEnd += 1;
}
if (tagEnd > j && tagEnd < sql.length && sql[tagEnd] === "$") {
const tag = sql.slice(j, tagEnd);
result += "$[REDACTED]$";
i = tagEnd + 1;
const closing = "$" + tag + "$";
while (i + closing.length <= sql.length) {
if (sql.slice(i, i + closing.length) === closing) {
i += closing.length;
break;
}
i += 1;
}
continue;
}
result += "$";
i += 1;
continue;
}
if (ch === "-" && next === "-") {
result += "--[REDACTED_COMMENT]";
i += 2;
while (i < sql.length && sql[i] !== "\n" && sql[i] !== "\r") i += 1;
continue;
}
if (ch === "/" && next === "*") {
result += "/*[REDACTED_COMMENT]*/";
i += 2;
while (i < sql.length && !(sql[i] === "*" && sql[i + 1] === "/")) i += 1;
if (i < sql.length) i += 2;
continue;
}
result += ch;
i += 1;
}
return result;
}
export function redactSqlForDiagnostics(sql: string, maxChars = DEFAULT_SQL_DIAGNOSTIC_MAX_CHARS): string {
const [boundedSql, inputTruncated] = boundedInput(sql, maxChars);
const literalRedacted = redactSqlLiterals(boundedSql);
const sensitiveRedacted = literalRedacted.replace(/\b([A-Za-z_][\w.-]*)(\s*[:=]\s*)([^\s,;)]+)/g, (match, key: string, separator: string) => {
if (!SENSITIVE_NAME_RE.test(key)) return match;
return `${key}${separator}[REDACTED]`;
});
return truncateForDiagnostic(sensitiveRedacted, maxChars, inputTruncated);
}
export function sqlDiagnosticsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
const value = env.DBX_SQL_DEBUG ?? env.DBX_DEBUG_SQL ?? env.DBX_MCP_DEBUG_SQL;
return value === "1" || value?.toLowerCase() === "true";
}
export function logSqlDiagnostic(scope: string, sql: string, details: Record<string, unknown> = {}, env?: NodeJS.ProcessEnv): void {
if (!sqlDiagnosticsEnabled(env)) return;
console.error(`[${scope}] sql:`, JSON.stringify({ ...details, sql: redactSqlForDiagnostics(sql) }));
}

View File

@ -1,254 +0,0 @@
export type SqlRiskLevel = "read" | "write" | "ddl" | "transaction" | "unknown";
export interface SqlRiskStatementAssessment {
risk: SqlRiskLevel;
firstKeyword?: string;
}
export interface SqlRiskAssessment extends SqlRiskStatementAssessment {
statements: SqlRiskStatementAssessment[];
}
/** Options for SQL text utilities that parse comments and literals. */
export interface SqlTextOptions {
/** Whether `#` starts a line comment. Only MySQL-family databases support this.
* Default: false (fail-safe `#` is treated as an operator, which may over-block
* MySQL classification but never under-blocks PostgreSQL). */
hashLineComments?: boolean;
}
/** Database types whose SQL dialect uses `#` for line comments (MySQL family). */
const MYSQL_HASH_COMMENT_DB_TYPES = new Set(["mysql", "doris", "starrocks", "manticoresearch", "goldendb"]);
/** Determine whether the given database type supports `#` line comments.
* Mirrors the Rust `is_mysql_compatible_database` dialect set:
* Mysql, Doris, StarRocks, ManticoreSearch, Goldendb. */
export function supportsHashLineComments(dbType?: string): boolean {
if (!dbType) return false;
return MYSQL_HASH_COMMENT_DB_TYPES.has(dbType);
}
interface SqlRiskToken {
text: string;
normalized: string;
}
const READ_KEYWORDS = new Set(["select", "show", "describe", "desc", "values", "table"]);
const WRITE_KEYWORDS = new Set(["insert", "update", "delete", "merge", "replace", "upsert", "load", "call", "exec", "execute", "flush"]);
const DDL_KEYWORDS = new Set(["create", "alter", "drop", "truncate", "rename", "grant", "revoke", "deny", "comment", "reindex", "vacuum", "optimize"]);
const TRANSACTION_KEYWORDS = new Set(["begin", "start", "commit", "rollback", "abort", "savepoint", "release"]);
const EXPLAIN_OPTION_KEYWORDS = new Set(["explain", "analyze", "analyse", "verbose", "query", "plan", "format", "type", "costs", "buffers", "timing", "summary", "settings", "wal", "generic_plan"]);
const PRIMARY_STATEMENT_KEYWORDS = new Set([...READ_KEYWORDS, ...WRITE_KEYWORDS, ...DDL_KEYWORDS, ...TRANSACTION_KEYWORDS, "with", "copy", "pragma", "use", "set"]);
const SAFE_READ_PRAGMA_NAMES = new Set(["table_info", "table_xinfo", "index_list", "index_info", "foreign_key_list", "database_list", "compile_options", "data_version"]);
const RISK_ORDER: Record<SqlRiskLevel, number> = { read: 0, write: 1, ddl: 2, transaction: 3, unknown: 4 };
export function splitSqlStatementsForSafety(sql: string, options?: SqlTextOptions): string[] {
return sqlSafetyText(sql, options)
.split(";")
.map((statement) => statement.trim())
.filter(Boolean);
}
export function classifySqlRisk(sql: string, options?: SqlTextOptions): SqlRiskAssessment {
const statements = splitSqlStatementsForSafety(sql, options).map(classifySqlStatementRisk);
if (!statements.length) return { risk: "unknown", statements: [] };
const highest = statements.reduce<SqlRiskStatementAssessment>((current, statement) => (RISK_ORDER[statement.risk] > RISK_ORDER[current.risk] ? statement : current), { risk: "read" });
return { ...highest, statements };
}
export function classifySqlStatementRisk(sql: string): SqlRiskStatementAssessment {
return classifyTokens(tokenizeSqlForRisk(sql));
}
export function isSqlRiskMutation(risk: SqlRiskLevel): boolean {
return risk !== "read";
}
export function sqlSafetyText(sql: string, options?: SqlTextOptions): string {
let output = "";
let index = 0;
const hashLineComments = options?.hashLineComments === true;
while (index < sql.length) {
const char = sql[index] ?? "";
const next = sql[index + 1] ?? "";
if (char === "-" && next === "-") {
index += 2;
while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1;
output += " ";
continue;
}
if (hashLineComments && char === "#") {
index += 1;
while (index < sql.length && sql[index] !== "\n" && sql[index] !== "\r") index += 1;
output += " ";
continue;
}
if (char === "/" && next === "*") {
const close = sql.indexOf("*/", index + 2);
if (close < 0) return output;
const executablePrefixLength = mysqlExecutableCommentPrefixLength(sql, index);
if (executablePrefixLength > 0) {
const bodyStart = skipExecutableCommentVersion(sql, index + executablePrefixLength);
output += ` ${sqlSafetyText(sql.slice(bodyStart, close), options)} `;
} else {
output += " ";
}
index = close + 2;
continue;
}
const dollarQuote = dollarQuoteTagAt(sql, index);
if (dollarQuote) {
const close = sql.indexOf(dollarQuote, index + dollarQuote.length);
index = close < 0 ? sql.length : close + dollarQuote.length;
output += " ";
continue;
}
if (char === "'") {
index = readQuotedEnd(sql, index, "'", "'");
output += " ";
continue;
}
if (char === '"' || char === "`" || char === "[") {
const close = char === "[" ? "]" : char;
const end = readQuotedEnd(sql, index, char, close);
output += ` ${unquoteIdentifier(sql.slice(index, end), char, close).replace(/[;]/g, " ")} `;
index = end;
continue;
}
output += char;
index += 1;
}
return output;
}
function tokenizeSqlForRisk(sql: string): SqlRiskToken[] {
const tokens: SqlRiskToken[] = [];
const re = /[A-Za-z_@$#][A-Za-z0-9_@$#-]*|[0-9]+|[(),.;*]|\S/g;
for (const match of sql.matchAll(re)) {
const text = match[0] ?? "";
tokens.push({ text, normalized: /^[A-Za-z_@$#]/.test(text) ? text.toLowerCase() : text });
}
return tokens;
}
function classifyTokens(tokens: SqlRiskToken[]): SqlRiskStatementAssessment {
const useful = trimWrappingParentheses(tokens);
const firstKeyword = useful.find((token) => /^[a-z_]/i.test(token.text))?.normalized;
if (!firstKeyword) return { risk: "unknown" };
if (READ_KEYWORDS.has(firstKeyword)) {
return { risk: firstKeyword === "select" && hasTopLevelSelectInto(useful) ? "write" : "read", firstKeyword };
}
if (firstKeyword === "with") return { risk: highestRiskInTokens(useful) ?? "read", firstKeyword };
if (firstKeyword === "explain") return classifyExplainTokens(useful);
if (firstKeyword === "copy") return { risk: classifyCopyTokens(useful), firstKeyword };
if (firstKeyword === "pragma") return { risk: classifyPragmaTokens(useful), firstKeyword };
if (firstKeyword === "use") return { risk: "read", firstKeyword };
if (WRITE_KEYWORDS.has(firstKeyword)) return { risk: "write", firstKeyword };
if (DDL_KEYWORDS.has(firstKeyword)) return { risk: "ddl", firstKeyword };
if (TRANSACTION_KEYWORDS.has(firstKeyword)) return { risk: "transaction", firstKeyword };
return { risk: "unknown", firstKeyword };
}
function classifyExplainTokens(tokens: SqlRiskToken[]): SqlRiskStatementAssessment {
const analyze = tokens.some((token) => token.normalized === "analyze" || token.normalized === "analyse");
const innerIndex = tokens.findIndex((token, index) => index > 0 && PRIMARY_STATEMENT_KEYWORDS.has(token.normalized) && !EXPLAIN_OPTION_KEYWORDS.has(token.normalized));
if (innerIndex < 0) return { risk: "read", firstKeyword: "explain" };
const inner = classifyTokens(tokens.slice(innerIndex));
if (!analyze) return { risk: inner.risk === "unknown" ? "unknown" : "read", firstKeyword: "explain" };
return { risk: inner.risk, firstKeyword: inner.firstKeyword ?? "explain" };
}
function classifyCopyTokens(tokens: SqlRiskToken[]): SqlRiskLevel {
if (tokens.some((token) => token.normalized === "from")) return "write";
if (tokens.some((token) => token.normalized === "to")) return "read";
return "unknown";
}
function classifyPragmaTokens(tokens: SqlRiskToken[]): SqlRiskLevel {
const name = tokens.find((token, index) => index > 0 && /^[a-z_]/i.test(token.text))?.normalized;
if (name && SAFE_READ_PRAGMA_NAMES.has(name) && !tokens.some((token) => token.text === "=")) return "read";
return "write";
}
function highestRiskInTokens(tokens: SqlRiskToken[]): SqlRiskLevel | undefined {
let result: SqlRiskLevel | undefined;
for (const token of tokens) {
const risk = WRITE_KEYWORDS.has(token.normalized) ? "write" : DDL_KEYWORDS.has(token.normalized) ? "ddl" : TRANSACTION_KEYWORDS.has(token.normalized) ? "transaction" : undefined;
if (risk && (!result || RISK_ORDER[risk] > RISK_ORDER[result])) result = risk;
}
return result;
}
function hasTopLevelSelectInto(tokens: SqlRiskToken[]): boolean {
let depth = 0;
for (const token of tokens) {
if (token.text === "(") depth += 1;
if (token.text === ")") depth = Math.max(0, depth - 1);
if (depth !== 0) continue;
if (token.normalized === "into") return true;
}
return false;
}
function trimWrappingParentheses(tokens: SqlRiskToken[]): SqlRiskToken[] {
let start = 0;
let end = tokens.length;
while (tokens[start]?.text === "(" && matchingParenIndex(tokens, start) === end - 1) {
start += 1;
end -= 1;
}
return tokens.slice(start, end);
}
function matchingParenIndex(tokens: readonly SqlRiskToken[], openIndex: number): number {
let depth = 0;
for (let index = openIndex; index < tokens.length; index += 1) {
if (tokens[index]?.text === "(") depth += 1;
if (tokens[index]?.text === ")") {
depth -= 1;
if (depth === 0) return index;
}
}
return -1;
}
function mysqlExecutableCommentPrefixLength(sql: string, index: number): number {
if (sql[index] !== "/" || sql[index + 1] !== "*") return 0;
if (sql[index + 2] === "!") return 3;
if (sql[index + 2] === "M" && sql[index + 3] === "!") return 4;
return 0;
}
function skipExecutableCommentVersion(sql: string, index: number): number {
let cursor = index;
while (cursor < sql.length && /[0-9\s]/.test(sql[cursor] ?? "")) cursor += 1;
return cursor;
}
function dollarQuoteTagAt(sql: string, index: number): string | undefined {
return sql.slice(index).match(/^\$[A-Za-z_][A-Za-z0-9_]*\$|^\$\$/)?.[0];
}
function readQuotedEnd(sql: string, start: number, open: string, close: string): number {
let index = start + open.length;
while (index < sql.length) {
if (sql[index] === "\\" && (open === "'" || open === '"')) {
index += 2;
continue;
}
if (sql.startsWith(close, index)) {
if (sql.startsWith(close + close, index)) {
index += close.length * 2;
continue;
}
return index + close.length;
}
index += 1;
}
return sql.length;
}
function unquoteIdentifier(value: string, open: string, close: string): string {
if (!value.startsWith(open) || !value.endsWith(close)) return value;
return value.slice(open.length, value.length - close.length).replaceAll(close + close, close);
}

View File

@ -1,189 +0,0 @@
import { classifySqlStatementRisk, splitSqlStatementsForSafety, sqlSafetyText, type SqlTextOptions } from "./sql-risk.js";
export interface SqlSafetyOptions {
allowWrites?: boolean;
allowDangerous?: boolean;
allowMultipleStatements?: boolean;
/** Whether `#` starts a line comment (MySQL family only). Default: false. */
hashLineComments?: boolean;
}
export interface SqlSafetyDecision {
allowed: boolean;
reason?: string;
}
const DANGEROUS_RISKS = new Set(["ddl", "transaction", "unknown"]);
function parseBooleanEnv(value: string | undefined): boolean | undefined {
if (value === undefined) return undefined;
const normalized = value.trim().toLowerCase();
if (normalized === "1" || normalized === "true") return true;
if (normalized === "0" || normalized === "false") return false;
return undefined;
}
export function evaluateSqlSafety(sql: string, options: SqlSafetyOptions = {}): SqlSafetyDecision {
const statements = splitSqlStatementsForSafety(sql, options);
if (statements.length === 0) return { allowed: false, reason: "SQL is empty." };
if (statements.length > 1 && !options.allowMultipleStatements) {
return { allowed: false, reason: "Only one SQL statement is allowed per query." };
}
for (let i = 0; i < statements.length; i++) {
const decision = evaluateSingleSqlStatementSafety(statements[i], options);
if (!decision.allowed && statements.length > 1) {
return {
allowed: false,
reason: `Statement ${i + 1}: ${decision.reason ?? "SQL blocked."}`,
};
}
if (!decision.allowed) return decision;
}
return { allowed: true };
}
function evaluateSingleSqlStatementSafety(sql: string, options: SqlSafetyOptions = {}): SqlSafetyDecision {
const assessment = classifySqlStatementRisk(sql);
const firstKeyword = assessment.firstKeyword;
if (!firstKeyword) return { allowed: false, reason: "SQL statement is not recognized." };
if (DANGEROUS_RISKS.has(assessment.risk) && !options.allowDangerous) {
return { allowed: false, reason: `Dangerous SQL or unrecognized SQL statement "${firstKeyword.toUpperCase()}" is blocked.` };
}
if (!options.allowWrites && assessment.risk !== "read") {
return {
allowed: false,
reason: "MCP SQL execution is read-only for this session. Set DBX_MCP_ALLOW_WRITES=1 to allow write statements.",
};
}
if (options.allowWrites && !options.allowDangerous) {
const tokens: string[] = sqlSafetyText(sql, options).toLowerCase().match(/[a-z_]+/g) ?? [];
if (firstKeyword === "update" && !tokens.includes("where")) {
return { allowed: false, reason: "UPDATE statements must include a WHERE clause." };
}
if (firstKeyword === "delete" && !tokens.includes("where")) {
return { allowed: false, reason: "DELETE statements must include a WHERE clause." };
}
}
return { allowed: true };
}
export function sqlSafetyFromEnv(env: NodeJS.ProcessEnv = process.env): SqlSafetyOptions {
const allowWrites = parseBooleanEnv(env.DBX_MCP_ALLOW_WRITES);
const allowDangerous = parseBooleanEnv(env.DBX_MCP_ALLOW_DANGEROUS_SQL);
return {
allowWrites: allowWrites ?? true,
allowDangerous: allowDangerous ?? false,
};
}
export function splitSqlStatements(sql: string, options?: SqlTextOptions): string[] {
const statements: string[] = [];
let statementStart = 0;
let index = 0;
let state: "none" | "single" | "double" | "backtick" | "bracket" | "lineComment" | "blockComment" | "dollar" = "none";
let dollarTag = "";
const hashLineComments = options?.hashLineComments === true;
const pushStatement = (end: number) => {
const statement = sql.slice(statementStart, end).trim();
if (statement) statements.push(statement);
};
while (index < sql.length) {
const char = sql[index] ?? "";
const next = sql[index + 1] ?? "";
if (state === "lineComment") {
if (char === "\n" || char === "\r") state = "none";
index += 1;
continue;
}
if (state === "blockComment") {
if (char === "*" && next === "/") {
state = "none";
index += 2;
} else {
index += 1;
}
continue;
}
if (state === "dollar") {
if (sql.startsWith(dollarTag, index)) {
index += dollarTag.length;
state = "none";
} else {
index += 1;
}
continue;
}
if (state === "single" || state === "double" || state === "backtick") {
const quote = state === "single" ? "'" : state === "double" ? '"' : "`";
if (char === quote) {
if (next === quote) {
index += 2;
continue;
}
state = "none";
} else if (char === "\\" && next) {
// Preserve dialects that accept backslash escapes without letting an escaped quote end the literal.
index += 2;
continue;
}
index += 1;
continue;
}
if (state === "bracket") {
if (char === "]") {
if (next === "]") {
index += 2;
continue;
}
state = "none";
}
index += 1;
continue;
}
if (char === "-" && next === "-") {
state = "lineComment";
index += 2;
continue;
}
if (hashLineComments && char === "#") {
state = "lineComment";
index += 1;
continue;
}
if (char === "/" && next === "*") {
state = "blockComment";
index += 2;
continue;
}
if (char === "'") state = "single";
else if (char === '"') state = "double";
else if (char === "`") state = "backtick";
else if (char === "[") state = "bracket";
else if (char === "$") {
const match = /^\$[A-Za-z_0-9]*\$/.exec(sql.slice(index));
if (match) {
dollarTag = match[0];
state = "dollar";
index += dollarTag.length;
continue;
}
} else if (char === ";") {
pushStatement(index);
statementStart = index + 1;
}
index += 1;
}
pushStatement(sql.length);
return statements;
}

View File

@ -1,452 +0,0 @@
import type { ConnectionConfig } from "./connections.js";
import type { TableInfo, ColumnInfo, QueryOptions, QueryResult } from "./database.js";
import {
collectionListToTableInfos,
evaluateMongoAggregateSafety,
evaluateMongoWriteSafety,
inferMongoColumns,
mongoCollectionStatsToQueryResult,
mongoDistinctToQueryResult,
mongoDocumentsToQueryResult,
parseMongoAggregateCommand,
parseMongoCollectionStatsCommand,
parseMongoCountDocumentsCommand,
parseMongoDistinctCommand,
parseMongoFindCommand,
parseMongoGetIndexesCommand,
parseMongoVersionCommand,
parseMongoWriteCommand,
type CollectionInfo,
type MongoWriteCommand,
} from "./database.js";
import type { RedisCommandOptions, RedisCommandResult } from "./redis-command.js";
import { sqlSafetyFromEnv } from "./sql-safety.js";
let sessionCookie: string | null = null;
let authChecked = false;
interface AuthCheckResponse {
authenticated: boolean;
required: boolean;
setup_required: boolean;
}
function baseUrl(): string {
return process.env.DBX_WEB_URL!.replace(/\/+$/, "");
}
function webPassword(): string {
return process.env.DBX_WEB_PASSWORD || "";
}
function extractSessionCookie(setCookie: string | null): string | null {
const match = setCookie?.match(/dbx_session=([^;]+)/);
return match?.[1] ?? null;
}
async function checkAuth(): Promise<AuthCheckResponse> {
const res = await fetch(`${baseUrl()}/api/auth/check`, {
method: "GET",
redirect: "manual",
});
if (!res.ok) {
throw new Error(`Authentication check failed: ${res.status} ${res.statusText}`);
}
return (await res.json()) as AuthCheckResponse;
}
async function ensureAuth(): Promise<void> {
if (sessionCookie) return;
if (authChecked) return;
const auth = await checkAuth();
if (auth.setup_required) {
throw new Error("DBX Web password setup is required before MCP Web mode can access APIs.");
}
if (!auth.required || auth.authenticated) {
authChecked = true;
return;
}
const password = webPassword();
if (!password) {
throw new Error("DBX Web authentication is required. Set DBX_WEB_PASSWORD for MCP Web mode.");
}
const res = await fetch(`${baseUrl()}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password }),
redirect: "manual",
});
if (!res.ok) {
throw new Error(`Authentication failed: ${res.status} ${res.statusText}`);
}
sessionCookie = extractSessionCookie(res.headers.get("set-cookie"));
if (!sessionCookie) {
throw new Error("Authentication failed: DBX Web did not return a session cookie.");
}
authChecked = true;
}
function headers(extra?: Record<string, string>): Record<string, string> {
const h: Record<string, string> = { "Content-Type": "application/json", ...extra };
if (sessionCookie) {
h["Cookie"] = `dbx_session=${sessionCookie}`;
}
return h;
}
async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
await ensureAuth();
let res = await fetch(`${baseUrl()}${path}`, {
...init,
headers: headers(init?.headers as Record<string, string> | undefined),
});
if (res.status === 401 && sessionCookie && webPassword()) {
sessionCookie = null;
authChecked = false;
await ensureAuth();
res = await fetch(`${baseUrl()}${path}`, {
...init,
headers: headers(init?.headers as Record<string, string> | undefined),
});
}
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`API request ${path} failed: ${res.status} ${res.statusText} ${body}`);
}
return res;
}
export function resetWebAuthForTests(): void {
sessionCookie = null;
authChecked = false;
}
export async function loadConnections(): Promise<ConnectionConfig[]> {
const res = await apiFetch("/api/connection/list");
return res.json();
}
export async function findConnection(name: string): Promise<ConnectionConfig | undefined> {
const connections = await loadConnections();
return connections.find((c) => c.name.toLowerCase() === name.toLowerCase());
}
export async function addConnection(config: Omit<ConnectionConfig, "id">): Promise<ConnectionConfig> {
const res = await apiFetch("/api/connection/save", {
method: "POST",
body: JSON.stringify({ configs: [config] }),
});
const saved = (await res.json()) as ConnectionConfig;
return saved;
}
export async function removeConnection(name: string): Promise<boolean> {
const connection = await findConnection(name);
if (!connection) return false;
await apiFetch(`/api/connection/delete?id=${encodeURIComponent(connection.id)}`, { method: "DELETE" });
return true;
}
export async function removeConnectionById(id: string): Promise<boolean> {
const connection = await loadConnections().then((cs) => cs.find((c) => c.id === id));
if (!connection) return false;
await apiFetch(`/api/connection/delete?id=${encodeURIComponent(id)}`, { method: "DELETE" });
return true;
}
async function ensureConnected(config: ConnectionConfig): Promise<void> {
await apiFetch("/api/connection/connect", {
method: "POST",
body: JSON.stringify({ config }),
});
}
export async function listTables(config: ConnectionConfig, schema?: string): Promise<TableInfo[]> {
await ensureConnected(config);
if (config.db_type === "mongodb") {
const res = await apiFetch("/api/mongo/list-collections", {
method: "POST",
body: JSON.stringify({ connectionId: config.id, database: config.database || "" }),
});
const collections = (await res.json()) as Array<string | CollectionInfo>;
return collectionListToTableInfos(collections);
}
const params = new URLSearchParams({
connection_id: config.id,
database: config.database || "",
schema: schema || "",
});
const res = await apiFetch(`/api/schema/tables?${params}`);
return res.json();
}
export async function describeTable(config: ConnectionConfig, table: string, schema?: string): Promise<ColumnInfo[]> {
await ensureConnected(config);
if (config.db_type === "mongodb") {
const res = await apiFetch("/api/mongo/find-documents", {
method: "POST",
body: JSON.stringify({ connectionId: config.id, database: config.database || "", collection: table, skip: 0, limit: 20, filter: "{}" }),
});
const result = (await res.json()) as { documents: unknown[]; total: number };
return inferMongoColumns(result.documents);
}
const params = new URLSearchParams({
connection_id: config.id,
database: config.database || "",
schema: schema || "",
table,
});
const res = await apiFetch(`/api/schema/columns?${params}`);
return res.json();
}
export async function executeQuery(config: ConnectionConfig, sql: string, options?: QueryOptions): Promise<QueryResult> {
await ensureConnected(config);
if (config.db_type === "mongodb") {
if (parseMongoVersionCommand(sql)) {
const res = await apiFetch("/api/mongo/server-version", {
method: "POST",
body: JSON.stringify({
connectionId: config.id,
database: config.database || "",
}),
});
const version = (await res.json()) as string;
return { columns: ["version"], rows: [{ version }], row_count: 1 };
}
const count = parseMongoCountDocumentsCommand(sql);
if (count) {
const res = await apiFetch("/api/mongo/count-documents", {
method: "POST",
body: JSON.stringify({
connectionId: config.id,
database: config.database || "",
collection: count.collection,
filter: count.filter,
mode: count.mode,
}),
});
const total = (await res.json()) as number;
return { columns: ["count"], rows: [{ count: total }], row_count: 1 };
}
const find = parseMongoFindCommand(sql);
if (find) {
const res = await apiFetch("/api/mongo/find-documents", {
method: "POST",
body: JSON.stringify({
connectionId: config.id,
database: config.database || "",
collection: find.collection,
skip: find.skip,
limit: find.limit,
filter: find.filter,
projection: find.projection,
sort: find.sort,
}),
});
const result = (await res.json()) as { documents: unknown[]; total: number };
return mongoDocumentsToQueryResult(result.documents.slice(0, options?.maxRows ?? result.documents.length), result.total);
}
const aggregate = parseMongoAggregateCommand(sql);
if (aggregate) {
const safety = evaluateMongoAggregateSafety(aggregate, sqlSafetyFromEnv());
if (!safety.allowed) throw new Error(safety.reason);
const res = await apiFetch("/api/mongo/aggregate-documents", {
method: "POST",
body: JSON.stringify({
connectionId: config.id,
database: config.database || "",
collection: aggregate.collection,
pipelineJson: aggregate.pipeline,
maxRows: options?.maxRows ?? 100,
...(aggregate.options ? { optionsJson: aggregate.options } : {}),
}),
});
const result = (await res.json()) as { documents: unknown[]; total: number };
return mongoDocumentsToQueryResult(result.documents.slice(0, options?.maxRows ?? result.documents.length), result.total);
}
const distinct = parseMongoDistinctCommand(sql);
if (distinct) {
const res = await apiFetch("/api/mongo/distinct", {
method: "POST",
body: JSON.stringify({
connectionId: config.id,
database: config.database || "",
collection: distinct.collection,
field: distinct.field,
filter: distinct.filter,
}),
});
const result = (await res.json()) as { documents: unknown[]; total: number };
return mongoDistinctToQueryResult(distinct.field, result.documents.slice(0, options?.maxRows ?? result.documents.length));
}
const getIndexes = parseMongoGetIndexesCommand(sql);
if (getIndexes) {
const res = await apiFetch("/api/mongo/aggregate-documents", {
method: "POST",
body: JSON.stringify({
connectionId: config.id,
database: config.database || "",
collection: getIndexes.collection,
pipelineJson: '[{"$indexStats":{}}]',
maxRows: options?.maxRows ?? 100,
}),
});
const result = (await res.json()) as { documents: unknown[]; total: number };
return mongoDocumentsToQueryResult(result.documents.slice(0, options?.maxRows ?? result.documents.length), result.total);
}
const collectionStats = parseMongoCollectionStatsCommand(sql);
if (collectionStats) {
const res = await apiFetch("/api/mongo/collection-stats", {
method: "POST",
body: JSON.stringify({
connectionId: config.id,
database: config.database || "",
collection: collectionStats.collection,
scale: collectionStats.scale,
}),
});
const result = (await res.json()) as Record<string, unknown>;
return mongoCollectionStatsToQueryResult(collectionStats.metric, result);
}
const write = parseMongoWriteCommand(sql);
if (write) {
const safety = evaluateMongoWriteSafety(write, sqlSafetyFromEnv());
if (!safety.allowed) throw new Error(safety.reason);
const result = await executeMongoWrite(config, write);
if (write.kind === "createIndex") {
return { columns: ["name"], rows: [{ name: result.indexName ?? "" }], row_count: 1 };
}
if (write.kind === "dropIndex" || write.kind === "dropIndexes") {
return { columns: ["name"], rows: (result.droppedNames ?? []).map((name) => ({ name })), row_count: result.affectedRows };
}
return { columns: [], rows: [], row_count: result.affectedRows };
}
throw new Error(
'Use MongoDB shell-style commands, for example: db.projects.find({}).limit(100), db.projects.aggregate([]), db.projects.aggregate([], {explain:true}), db.version(), db.projects.countDocuments({}), db.projects.count({}), db.projects.distinct("status"), db.projects.getIndexes(), db.projects.dataSize(), db.projects.storageSize(1024), db.projects.totalIndexSize(), db.projects.stats(), db.projects.createIndex({...}), db.projects.dropIndex("name"), db.projects.dropIndexes(), db.projects.drop(), db.projects.insertOne({...}), db.projects.updateOne({...}, {$set: {...}}), or db.projects.deleteOne({...})',
);
}
const res = await apiFetch("/api/query/execute", {
method: "POST",
body: JSON.stringify({
connectionId: config.id,
database: config.database || "",
sql,
}),
});
const data = (await res.json()) as { columns: string[]; rows: unknown[][] };
const rows = data.rows.map((row: unknown[]) => {
const obj: Record<string, unknown> = {};
data.columns.forEach((col: string, i: number) => {
obj[col] = row[i];
});
return obj;
});
const limitedRows = rows.slice(0, options?.maxRows ?? rows.length);
return { columns: data.columns, rows: limitedRows, row_count: limitedRows.length };
}
export async function executeRedisCommand(config: ConnectionConfig, db: number, command: string, options?: RedisCommandOptions): Promise<RedisCommandResult> {
if (config.db_type !== "redis") {
throw new Error("Connection is not Redis.");
}
await ensureConnected(config);
const res = await apiFetch("/api/redis/execute-command", {
method: "POST",
body: JSON.stringify({
connectionId: config.id,
db,
command,
skipSafetyCheck: options?.skipSafetyCheck ?? false,
}),
});
return (await res.json()) as RedisCommandResult;
}
async function executeMongoWrite(config: ConnectionConfig, command: MongoWriteCommand): Promise<{ affectedRows: number; indexName?: string; droppedNames?: string[] }> {
if (command.kind === "insert") {
const res = await apiFetch("/api/mongo/insert-documents", {
method: "POST",
body: JSON.stringify({
connectionId: config.id,
database: config.database || "",
collection: command.collection,
docsJson: command.docsJson,
}),
});
const result = (await res.json()) as { affected_rows: number };
return { affectedRows: result.affected_rows };
}
if (command.kind === "update") {
const res = await apiFetch("/api/mongo/update-documents", {
method: "POST",
body: JSON.stringify({
connectionId: config.id,
database: config.database || "",
collection: command.collection,
filterJson: command.filter,
updateJson: command.update,
many: command.many,
optionsJson: command.options,
}),
});
const result = (await res.json()) as { affected_rows: number };
return { affectedRows: result.affected_rows };
}
if (command.kind === "createIndex") {
const res = await apiFetch("/api/mongo/create-index", {
method: "POST",
body: JSON.stringify({
connectionId: config.id,
database: config.database || "",
collection: command.collection,
keysJson: command.keys,
optionsJson: command.options,
}),
});
const result = (await res.json()) as { name: string };
return { affectedRows: 1, indexName: result.name };
}
if (command.kind === "dropIndex" || command.kind === "dropIndexes") {
const res = await apiFetch("/api/mongo/drop-indexes", {
method: "POST",
body: JSON.stringify({
connectionId: config.id,
database: config.database || "",
collection: command.collection,
indexesJson: command.kind === "dropIndex" ? command.index : command.indexes,
single: command.kind === "dropIndex",
}),
});
const result = (await res.json()) as { dropped_names: string[]; affected_rows: number };
return { affectedRows: result.affected_rows, droppedNames: result.dropped_names };
}
if (command.kind === "dropCollection") {
await apiFetch("/api/mongo/drop-collection", {
method: "POST",
body: JSON.stringify({
connectionId: config.id,
database: config.database || "",
collection: command.collection,
}),
});
return { affectedRows: 1 };
}
const res = await apiFetch("/api/mongo/delete-documents", {
method: "POST",
body: JSON.stringify({
connectionId: config.id,
database: config.database || "",
collection: command.collection,
filterJson: command.filter,
many: command.many,
}),
});
const result = (await res.json()) as { affected_rows: number };
return { affectedRows: result.affected_rows };
}

View File

@ -1,23 +0,0 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { collectionListToTableInfos } from "../src/database.js";
test("maps legacy collection name responses to table infos", () => {
assert.deepEqual(collectionListToTableInfos(["projects", "users"]), [
{ name: "projects", type: "COLLECTION" },
{ name: "users", type: "COLLECTION" },
]);
});
test("maps collection info responses to table infos", () => {
assert.deepEqual(
collectionListToTableInfos([
{ name: "projects", id: "projects", dimension: null },
{ name: "embeddings", id: "uuid-123", dimension: 384 },
]),
[
{ name: "projects", type: "COLLECTION" },
{ name: "embeddings", type: "COLLECTION" },
],
);
});

View File

@ -1,52 +0,0 @@
import assert from "node:assert/strict";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "vitest";
import Database from "better-sqlite3";
import { inspectConnectionStore, loadConnections } from "../src/connections.js";
test("connection store diagnostics report rows even when loading fails", async () => {
const dir = await mkdtemp(join(tmpdir(), "dbx-store-"));
const path = join(dir, "dbx.db");
try {
const db = new Database(path);
db.exec(`
CREATE TABLE connections (id TEXT PRIMARY KEY, config_json TEXT NOT NULL);
CREATE TABLE connection_secrets (connection_id TEXT, key TEXT, secret TEXT);
`);
db.prepare("INSERT INTO connections (id, config_json) VALUES (?, ?)").run("broken", "{not json");
db.close();
await assert.rejects(() => loadConnections({ path }), /Failed to load DBX connections/);
const diagnostics = await inspectConnectionStore({ path });
assert.equal(diagnostics.dbPath, path);
assert.equal(diagnostics.dbPathExists, true);
assert.equal(diagnostics.connectionsTableExists, true);
assert.equal(diagnostics.connectionRowCount, 1);
assert.equal(diagnostics.loadConnectionsOk, false);
assert.match(diagnostics.loadConnectionsError ?? "", /Failed to load DBX connections/);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("missing connection store is treated as an empty store", async () => {
const dir = await mkdtemp(join(tmpdir(), "dbx-store-"));
const path = join(dir, "missing.db");
try {
assert.deepEqual(await loadConnections({ path }), []);
const diagnostics = await inspectConnectionStore({ path });
assert.equal(diagnostics.dbPathExists, false);
assert.equal(diagnostics.connectionsTableExists, false);
assert.equal(diagnostics.connectionRowCount, 0);
assert.equal(diagnostics.loadConnectionsOk, true);
assert.equal(diagnostics.loadedConnectionCount, 0);
} finally {
await rm(dir, { recursive: true, force: true });
}
});

View File

@ -1,295 +0,0 @@
import assert from "node:assert/strict";
import { afterEach, test, vi } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ConnectionConfig } from "../src/connections.js";
const fakeMysqlQuery = vi.fn();
const fakeMysqlEnd = vi.fn().mockResolvedValue(undefined);
const fakePgQuery = vi.fn();
const fakePgEnd = vi.fn().mockResolvedValue(undefined);
const fakePgOn = vi.fn();
vi.mock("mysql2/promise", () => ({
default: {
createPool: vi.fn(() => ({
query: fakeMysqlQuery,
end: fakeMysqlEnd,
})),
},
}));
vi.mock("pg", () => ({
default: {
Pool: vi.fn(function MockPool() {
return {
query: fakePgQuery,
end: fakePgEnd,
on: fakePgOn,
};
}),
},
}));
import { closeDatabaseResources, describeTable } from "../src/database.js";
function mysqlConfig(): ConnectionConfig {
return {
id: "mysql-direct-test",
name: "mysql-direct",
db_type: "mysql",
host: "127.0.0.1",
port: 3306,
username: "root",
password: "secret",
database: "app",
ssl: false,
};
}
function postgresConfig(): ConnectionConfig {
return {
id: "postgres-direct-test",
name: "postgres-direct",
db_type: "postgres",
host: "127.0.0.1",
port: 5432,
username: "postgres",
password: "postgres",
database: "app",
ssl: false,
};
}
const bridgeConfig: ConnectionConfig = {
id: "pg-bridge",
name: "bridge-postgres",
db_type: "postgres",
host: "127.0.0.1",
port: 5432,
username: "postgres",
password: "postgres",
database: "postgres",
ssl: false,
transport_layers: [
{
type: "ssh",
id: "jump",
enabled: true,
host: "bastion.internal",
port: 22,
user: "dbx",
},
],
};
afterEach(async () => {
await closeDatabaseResources();
fakeMysqlQuery.mockReset();
fakeMysqlEnd.mockClear();
fakePgQuery.mockReset();
fakePgEnd.mockClear();
fakePgOn.mockClear();
});
test("describeTable maps mysql enum_values from metadata", async () => {
fakeMysqlQuery.mockResolvedValue([
[
{
name: "state",
data_type: "enum",
column_type: "enum('pending','active','archived')",
is_nullable: 0,
column_default: "pending",
is_primary_key: 0,
comment: "workflow state",
},
],
[{ name: "name" }],
]);
const columns = await describeTable(mysqlConfig(), "orders");
assert.match(String(fakeMysqlQuery.mock.calls[0]?.[0] ?? ""), /COLUMN_TYPE AS column_type/);
assert.deepEqual(fakeMysqlQuery.mock.calls[0]?.[1], ["orders"]);
assert.deepEqual(columns, [
{
name: "state",
data_type: "enum",
is_nullable: false,
column_default: "pending",
is_primary_key: false,
comment: "workflow state",
enum_values: ["pending", "active", "archived"],
},
]);
});
test("describeTable parses mysql enum literal edge cases", async () => {
fakeMysqlQuery.mockResolvedValue([
[
{
name: "empty_state",
data_type: "enum",
column_type: "enum('','a')",
is_nullable: 1,
column_default: null,
is_primary_key: 0,
comment: null,
},
{
name: "quoted_state",
data_type: "enum",
column_type: "enum('x'',''y','z')",
is_nullable: 1,
column_default: null,
is_primary_key: 0,
comment: null,
},
{
name: "escaped_state",
data_type: "enum",
column_type: String.raw`enum('it''s','quote\"d','back\\slash')`,
is_nullable: 1,
column_default: null,
is_primary_key: 0,
comment: null,
},
],
[{ name: "name" }],
]);
const columns = await describeTable(mysqlConfig(), "orders");
assert.deepEqual(
columns.map((column) => column.enum_values),
[
["", "a"],
["x','y", "z"],
["it's", 'quote"d', "back\\slash"],
],
);
});
test("describeTable preserves enum values from bridge metadata", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "dbx-node-core-"));
const previousDataDir = process.env.DBX_DATA_DIR;
const server = createServer((req, res) => {
assert.equal(req.url, "/data/describe-table");
res.writeHead(200, { "content-type": "application/json" });
res.end(
JSON.stringify([
{
name: "state",
data_type: "status",
is_nullable: false,
column_default: null,
is_primary_key: false,
comment: "workflow state",
enum_values: ["pending", "active", "archived"],
},
]),
);
});
try {
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
const address = server.address();
if (!address || typeof address === "string") throw new Error("expected TCP bridge address");
process.env.DBX_DATA_DIR = tempDir;
await writeFile(join(tempDir, "mcp-bridge-port"), String(address.port));
const columns = await describeTable(bridgeConfig, "orders", "public");
assert.deepEqual(columns, [
{
name: "state",
data_type: "status",
is_nullable: false,
column_default: null,
is_primary_key: false,
comment: "workflow state",
enum_values: ["pending", "active", "archived"],
},
]);
} finally {
server.close();
if (previousDataDir === undefined) {
delete process.env.DBX_DATA_DIR;
} else {
process.env.DBX_DATA_DIR = previousDataDir;
}
await rm(tempDir, { recursive: true, force: true });
}
});
test("describeTable reads postgres enum_values from the primary metadata query", async () => {
fakePgQuery.mockResolvedValueOnce({
rows: [
{
name: "state",
data_type: "status",
is_nullable: false,
column_default: null,
is_primary_key: false,
comment: "workflow state",
enum_values: ["pending", "active", "archived"],
},
],
fields: [{ name: "name" }],
});
const columns = await describeTable(postgresConfig(), "orders", "public");
assert.match(String(fakePgQuery.mock.calls[0]?.[0] ?? ""), /FROM pg_enum e WHERE e\.enumtypid = enum_t\.oid/);
assert.equal(fakePgQuery.mock.calls.length, 1);
assert.deepEqual(columns, [
{
name: "state",
data_type: "status",
is_nullable: false,
column_default: null,
is_primary_key: false,
comment: "workflow state",
enum_values: ["pending", "active", "archived"],
},
]);
});
test("describeTable falls back to compat postgres metadata query when enum joins fail", async () => {
fakePgQuery.mockRejectedValueOnce(new Error("pg_enum catalog unavailable")).mockResolvedValueOnce({
rows: [
{
name: "state",
data_type: "status",
is_nullable: false,
column_default: null,
is_primary_key: false,
comment: "workflow state",
enum_values: null,
},
],
fields: [{ name: "name" }],
});
const columns = await describeTable(postgresConfig(), "orders", "public");
assert.match(String(fakePgQuery.mock.calls[0]?.[0] ?? ""), /FROM pg_enum e WHERE e\.enumtypid = enum_t\.oid/);
assert.match(String(fakePgQuery.mock.calls[1]?.[0] ?? ""), /NULL AS enum_values/);
assert.doesNotMatch(String(fakePgQuery.mock.calls[1]?.[0] ?? ""), /pg_enum/);
assert.deepEqual(columns, [
{
name: "state",
data_type: "status",
is_nullable: false,
column_default: null,
is_primary_key: false,
comment: "workflow state",
enum_values: null,
},
]);
});

View File

@ -1,98 +0,0 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { test } from "vitest";
import { BRIDGE_REQUIRED_TYPES, DIRECT_QUERY_TYPES, isDirectQueryType } from "../src/diagnostics.js";
interface DriverManifest {
drivers: Array<{
dbType: string;
mcpMode: "direct" | "bridge" | "unsupported";
supportLevel: "connect" | "browse" | "understand" | "operate";
capabilities: Record<DatabaseProductCapability, boolean>;
}>;
}
const PRODUCT_CAPABILITY_KEYS = [
"queryExecution",
"metadataBrowse",
"objectBrowser",
"objectSource",
"schemaSearch",
"diagram",
"tableDataEdit",
"tableStructureEdit",
"tableImport",
"dataTransfer",
"sqlFileExecution",
"databaseCreate",
"fieldLineage",
"sqlExplain",
"userAdmin",
"driverManagement",
] as const;
type DatabaseProductCapability = (typeof PRODUCT_CAPABILITY_KEYS)[number];
function loadManifest(): DriverManifest {
const path = fileURLToPath(new URL("../../../crates/dbx-core/assets/database-drivers.manifest.json", import.meta.url));
return JSON.parse(readFileSync(path, "utf8")) as DriverManifest;
}
test("diagnostic query mode lists match the driver manifest", () => {
const manifest = loadManifest();
const directTypes = manifest.drivers.filter((driver) => driver.mcpMode === "direct").map((driver) => driver.dbType);
const bridgeTypes = manifest.drivers.filter((driver) => driver.mcpMode === "bridge").map((driver) => driver.dbType);
assert.deepEqual([...DIRECT_QUERY_TYPES].sort(), directTypes.sort());
assert.deepEqual([...BRIDGE_REQUIRED_TYPES].sort(), bridgeTypes.sort());
});
test("runtime direct query routing matches diagnostic direct query types", () => {
for (const dbType of DIRECT_QUERY_TYPES) {
assert.equal(isDirectQueryType(dbType), true, `${dbType} should use direct query routing`);
}
for (const dbType of BRIDGE_REQUIRED_TYPES) {
assert.equal(isDirectQueryType(dbType), false, `${dbType} should not use direct query routing`);
}
});
test("Manticore Search is direct-query capable", () => {
assert.equal(isDirectQueryType("manticoresearch"), true);
assert.equal(DIRECT_QUERY_TYPES.includes("manticoresearch" as any), true);
});
test("GaussDB family requires the DBX Desktop bridge for MCP", () => {
for (const dbType of ["gaussdb", "opengauss"] as const) {
assert.equal(isDirectQueryType(dbType), false);
assert.equal(DIRECT_QUERY_TYPES.includes(dbType as any), false);
assert.equal(BRIDGE_REQUIRED_TYPES.includes(dbType as any), true);
}
});
test("driver manifest declares support levels and product capabilities", () => {
const manifest = loadManifest();
for (const driver of manifest.drivers) {
assert.match(driver.supportLevel, /^(connect|browse|understand|operate)$/);
for (const key of PRODUCT_CAPABILITY_KEYS) {
assert.equal(typeof driver.capabilities[key], "boolean", `${driver.dbType}.${key} should be a boolean`);
}
assert.equal(Object.keys(driver.capabilities).sort().join(","), [...PRODUCT_CAPABILITY_KEYS].sort().join(","));
}
const jdbc = manifest.drivers.find((driver) => driver.dbType === "jdbc");
assert.equal(jdbc?.supportLevel, "browse");
assert.equal(jdbc?.capabilities.metadataBrowse, true);
assert.equal(jdbc?.capabilities.tableStructureEdit, false);
const manticore = manifest.drivers.find((driver) => driver.dbType === "manticoresearch");
assert.equal(manticore?.supportLevel, "operate");
assert.equal(manticore?.capabilities.queryExecution, true);
assert.equal(manticore?.capabilities.metadataBrowse, true);
assert.equal(manticore?.capabilities.objectBrowser, false);
assert.equal(manticore?.capabilities.tableDataEdit, true);
assert.equal(manticore?.capabilities.tableStructureEdit, true);
assert.equal(manticore?.capabilities.databaseCreate, false);
assert.equal(manticore?.capabilities.userAdmin, false);
});

View File

@ -1,53 +0,0 @@
import assert from "node:assert/strict";
import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { test } from "vitest";
import { isMainModule } from "../src/entrypoint.js";
test("matches a module invoked through its real file path", async () => {
const dir = await mkdtemp(join(tmpdir(), "dbx-entrypoint-"));
try {
const entry = join(dir, "cli.js");
await writeFile(entry, "", "utf-8");
assert.equal(isMainModule(pathToFileURL(entry).href, entry), true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("matches a module invoked through an npm-style symlink", async () => {
const dir = await mkdtemp(join(tmpdir(), "dbx-entrypoint-"));
try {
const entry = join(dir, "dist", "cli.js");
const bin = join(dir, "dbx");
await mkdir(join(dir, "dist"));
await writeFile(entry, "", "utf-8");
try {
await symlink(entry, bin);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EPERM") throw error;
return;
}
assert.equal(isMainModule(pathToFileURL(entry).href, bin), true);
} finally {
await rm(dir, { recursive: true, force: true });
}
});
test("does not match a different entry file", async () => {
const dir = await mkdtemp(join(tmpdir(), "dbx-entrypoint-"));
try {
const entry = join(dir, "cli.js");
const other = join(dir, "other.js");
await writeFile(entry, "", "utf-8");
await writeFile(other, "", "utf-8");
assert.equal(isMainModule(pathToFileURL(entry).href, other), false);
} finally {
await rm(dir, { recursive: true, force: true });
}
});

View File

@ -1,14 +0,0 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { formatCell, mdTable } from "../src/format.js";
test("formats markdown tables", () => {
assert.equal(mdTable(["Name", "Type"], [["local", "postgres"]]), "| Name | Type |\n| ----- | -------- |\n| local | postgres |");
});
test("formats database cell values", () => {
assert.equal(formatCell(null), "NULL");
assert.equal(formatCell(undefined), "NULL");
assert.equal(formatCell({ ok: true }), '{"ok":true}');
assert.equal(formatCell("hello"), "hello");
});

View File

@ -1,71 +0,0 @@
import assert from "node:assert/strict";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { createServer } from "node:http";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "vitest";
import type { ConnectionConfig } from "../src/connections.js";
import { executeQuery } from "../src/database.js";
const mongoConfig: ConnectionConfig = {
id: "mongo-bridge",
name: "mongo-bridge",
db_type: "mongodb",
host: "127.0.0.1",
port: 27017,
username: "",
password: "",
database: "app",
ssh_enabled: false,
ssl: false,
};
test("direct backend routes MongoDB count commands through the count bridge", async () => {
const tempDir = await mkdtemp(join(tmpdir(), "dbx-node-core-count-"));
const previousDataDir = process.env.DBX_DATA_DIR;
let requestBody: unknown;
const server = createServer((req, res) => {
assert.equal(req.url, "/data/mongo/count-documents");
let body = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => {
requestBody = JSON.parse(body);
res.writeHead(200, { "content-type": "application/json" });
res.end("42");
});
});
try {
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
if (!address || typeof address === "string") throw new Error("expected TCP bridge address");
process.env.DBX_DATA_DIR = tempDir;
await writeFile(join(tempDir, "mcp-bridge-port"), String(address.port));
const result = await executeQuery(mongoConfig, "db.projects.count({ active: true })");
assert.deepEqual(requestBody, {
connection_id: "mongo-bridge",
connection_name: "mongo-bridge",
database: "app",
collection: "projects",
filter: '{ "active": true }',
mode: "legacy",
});
assert.deepEqual(result, {
columns: ["count"],
rows: [{ count: 42 }],
row_count: 1,
});
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
if (previousDataDir === undefined) delete process.env.DBX_DATA_DIR;
else process.env.DBX_DATA_DIR = previousDataDir;
await rm(tempDir, { recursive: true, force: true });
}
});

View File

@ -1,476 +0,0 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import {
executeQuery,
inferMongoColumns,
mongoAggregateWriteStage,
mongoCollectionStatsToQueryResult,
mongoDocumentsToQueryResult,
describeMongoCommandParseFailure,
parseMongoAggregateCommand,
parseMongoCollectionStatsCommand,
parseMongoCountDocumentsCommand,
parseMongoFindCommand,
parseMongoGetIndexesCommand,
parseMongoVersionCommand,
parseMongoWriteCommand,
} from "../src/database.js";
test("parseMongoFindCommand accepts shell-style find commands", () => {
assert.deepEqual(parseMongoFindCommand('db.getCollection("operation_logs").find({"level":"info"}).sort({"ts":-1}).skip(5).limit(10)'), {
collection: "operation_logs",
filter: '{"level":"info"}',
skip: 5,
limit: 10,
sort: '{"ts":-1}',
});
});
test("parseMongoFindCommand accepts line breaks before find and chained calls", () => {
const command = parseMongoFindCommand(`db.getCollection("operation_logs")
.find({
"_id": ObjectId("68ad51ca84c8127bc7d44cb3")
})
.sort({ ts: -1 })
.skip(5)
.limit(10)`);
assert.ok(command);
assert.equal(command.collection, "operation_logs");
assert.deepEqual(JSON.parse(command.filter), { _id: { $oid: "68ad51ca84c8127bc7d44cb3" } });
assert.deepEqual(JSON.parse(command.sort || "{}"), { ts: -1 });
assert.equal(command.skip, 5);
assert.equal(command.limit, 10);
});
test("parseMongoFindCommand accepts Compass-style unquoted keys and ObjectId", () => {
const command = parseMongoFindCommand("db.products.find({_id: ObjectId('6a045a92d2971e44243771a1')}).limit(1)");
assert.ok(command);
assert.equal(command.collection, "products");
assert.equal(command.limit, 1);
assert.deepEqual(JSON.parse(command.filter), { _id: { $oid: "6a045a92d2971e44243771a1" } });
});
test("parseMongoFindCommand accepts projection arguments", () => {
const command = parseMongoFindCommand("db.jobs.find({status: 'open'}, {title: 1, _id: 0}).sort({title: 1})");
assert.ok(command);
assert.equal(command.collection, "jobs");
assert.deepEqual(JSON.parse(command.filter), { status: "open" });
assert.deepEqual(JSON.parse(command.projection || "{}"), { title: 1, _id: 0 });
assert.deepEqual(JSON.parse(command.sort || "{}"), { title: 1 });
});
test("parseMongoVersionCommand accepts db.version", () => {
assert.equal(parseMongoVersionCommand("db.version();"), true);
assert.equal(parseMongoVersionCommand("db.jobs.version()"), false);
});
test("parseMongoWriteCommand accepts unquoted update operator keys", () => {
assert.deepEqual(parseMongoWriteCommand("db.projects.updateOne({_id: ObjectId('507f1f77bcf86cd799439011')}, {$set: {name: 'next'}})"), {
kind: "update",
collection: "projects",
filter: '{"_id": {"$oid":"507f1f77bcf86cd799439011"}}',
update: '{"$set": {"name": "next"}}',
many: false,
});
});
test("parseMongoWriteCommand accepts updateMany arrayFilters options", () => {
assert.deepEqual(parseMongoWriteCommand('db.orders.updateMany({status: "open"}, {$set: {"items.$[item].status": "done"}}, {arrayFilters: [{"item.id": 7}]})'), {
kind: "update",
collection: "orders",
filter: '{"status": "open"}',
update: '{"$set": {"items.$[item].status": "done"}}',
options: '{"arrayFilters": [{"item.id": 7}]}',
many: true,
});
});
test("parseMongoCountDocumentsCommand accepts shell-style count commands", () => {
assert.deepEqual(parseMongoCountDocumentsCommand('db.projects.countDocuments({"active":true})'), {
collection: "projects",
filter: '{"active":true}',
mode: "accurate",
});
});
test("parseMongoCountDocumentsCommand accepts legacy count helpers", () => {
assert.deepEqual(parseMongoCountDocumentsCommand("db.projects.count({ active: true })"), {
collection: "projects",
filter: '{ "active": true }',
mode: "legacy",
});
assert.deepEqual(parseMongoCountDocumentsCommand('db.getCollection("audit.logs").count()'), {
collection: "audit.logs",
filter: "{}",
mode: "legacy",
});
assert.deepEqual(parseMongoCountDocumentsCommand("db.projects.find({ active: true }).count()"), {
collection: "projects",
filter: '{ "active": true }',
mode: "legacy",
});
assert.equal(parseMongoFindCommand("db.projects.find({ active: true }).count()"), null);
});
test("parseMongoAggregateCommand accepts aggregate pipelines", () => {
assert.deepEqual(parseMongoAggregateCommand('db.projects.aggregate([{"$match":{"active":true}},{"$group":{"_id":"$owner","total":{"$sum":1}}}])'), {
collection: "projects",
pipeline: '[{"$match":{"active":true}},{"$group":{"_id":"$owner","total":{"$sum":1}}}]',
});
});
test("parseMongoAggregateCommand accepts options including explain", () => {
const withExplain = parseMongoAggregateCommand("db.uc_user.aggregate([], {explain: true})");
assert.equal(withExplain?.collection, "uc_user");
assert.equal(withExplain?.pipeline, "[]");
assert.deepEqual(JSON.parse(withExplain?.options ?? "null"), { explain: true });
assert.equal(parseMongoAggregateCommand("db.uc_user.aggregate([], {explain: true"), null);
assert.equal(parseMongoAggregateCommand("db.products.aggregate([], [])"), null);
assert.equal(parseMongoAggregateCommand("db.products.aggregate([]).limit(10)"), null);
assert.deepEqual(parseMongoAggregateCommand("db.products.aggregate([], {})"), {
collection: "products",
pipeline: "[]",
options: "{}",
});
});
test("describeMongoCommandParseFailure reports aggregate-specific issues", () => {
assert.match(describeMongoCommandParseFailure("db.uc_user.aggregate([], {explain: true"), /unclosed/i);
assert.match(describeMongoCommandParseFailure("db.products.aggregate([]).limit(10)"), /chaining|not supported/i);
assert.match(describeMongoCommandParseFailure('db.products.aggregate({"$match":{}})'), /pipeline must be a JSON array/i);
assert.match(describeMongoCommandParseFailure("db.products.aggregate([], [])"), /options must be a JSON object/i);
assert.match(describeMongoCommandParseFailure("SELECT 1"), /MongoDB shell-style commands/i);
});
test("parseMongoGetIndexesCommand accepts shell-style index commands", () => {
assert.deepEqual(parseMongoGetIndexesCommand("db.web_log.getIndexes();"), {
collection: "web_log",
});
assert.deepEqual(parseMongoGetIndexesCommand('db.getCollection("audit.logs").getIndexes()'), {
collection: "audit.logs",
});
assert.equal(parseMongoGetIndexesCommand("db.web_log.getIndexes({})"), null);
});
test("parseMongoCollectionStatsCommand accepts Mongo shell stats helpers", () => {
assert.deepEqual(parseMongoCollectionStatsCommand("db.users.dataSize()"), {
collection: "users",
metric: "dataSize",
});
assert.deepEqual(parseMongoCollectionStatsCommand('db.getCollection("audit.logs").dataSize(1024)'), {
collection: "audit.logs",
metric: "dataSize",
scale: 1024,
});
assert.deepEqual(parseMongoCollectionStatsCommand("db.users.storageSize(1024)"), {
collection: "users",
metric: "storageSize",
scale: 1024,
});
assert.deepEqual(parseMongoCollectionStatsCommand("db.users.totalIndexSize()"), {
collection: "users",
metric: "totalIndexSize",
});
assert.deepEqual(parseMongoCollectionStatsCommand("db.users.stats()"), {
collection: "users",
metric: "stats",
});
assert.deepEqual(parseMongoCollectionStatsCommand("db.users.stats(1024)"), {
collection: "users",
metric: "stats",
scale: 1024,
});
});
test("parseMongoCollectionStatsCommand rejects unsupported stats helper arguments", () => {
assert.equal(parseMongoCollectionStatsCommand("db.users.dataSize(1, 2)"), null);
assert.equal(parseMongoCollectionStatsCommand("db.users.storageSize({scale: 1024})"), null);
assert.equal(parseMongoCollectionStatsCommand("db.users.stats().limit(1)"), null);
});
test("mongoCollectionStatsToQueryResult maps dataSize helper to collStats size", () => {
assert.deepEqual(mongoCollectionStatsToQueryResult("dataSize", { size: 2048 }), {
columns: ["dataSize"],
rows: [{ dataSize: 2048 }],
row_count: 1,
});
assert.deepEqual(
mongoCollectionStatsToQueryResult("stats", {
count: 3,
size: 128,
storageSize: 512,
totalIndexSize: 64,
}),
{
columns: ["count", "size", "avgObjSize", "storageSize", "totalIndexSize", "nindexes"],
rows: [{ count: 3, size: 128, avgObjSize: null, storageSize: 512, totalIndexSize: 64, nindexes: null }],
row_count: 1,
},
);
});
test("mongoAggregateWriteStage detects write stages", () => {
assert.equal(mongoAggregateWriteStage('[{"$match":{"active":true}}]'), null);
assert.equal(mongoAggregateWriteStage('[{"$match":{}},{"$out":"projects_dump"}]'), "$out");
assert.equal(mongoAggregateWriteStage('[{"$merge":{"into":"projects_dump"}}]'), "$merge");
});
test("mongodb executeQuery blocks aggregate write stages until dangerous SQL is enabled", async () => {
const oldAllowWrites = process.env.DBX_MCP_ALLOW_WRITES;
const oldAllowDangerous = process.env.DBX_MCP_ALLOW_DANGEROUS_SQL;
delete process.env.DBX_MCP_ALLOW_WRITES;
delete process.env.DBX_MCP_ALLOW_DANGEROUS_SQL;
const config = {
id: "mongo",
name: "mongo",
db_type: "mongodb",
host: "127.0.0.1",
port: 27017,
username: "",
password: "",
database: "app",
ssh_enabled: false,
ssl: false,
} as const;
await assert.rejects(executeQuery(config, 'db.projects.aggregate([{"$merge":{"into":"projects_dump"}}])'), /DBX_MCP_ALLOW_DANGEROUS_SQL=1/);
if (oldAllowWrites === undefined) delete process.env.DBX_MCP_ALLOW_WRITES;
else process.env.DBX_MCP_ALLOW_WRITES = oldAllowWrites;
if (oldAllowDangerous === undefined) delete process.env.DBX_MCP_ALLOW_DANGEROUS_SQL;
else process.env.DBX_MCP_ALLOW_DANGEROUS_SQL = oldAllowDangerous;
});
test("parseMongoWriteCommand accepts supported write commands", () => {
assert.deepEqual(parseMongoWriteCommand('db.projects.insertOne({"name":"demo"})'), {
kind: "insert",
collection: "projects",
docsJson: '{"name":"demo"}',
});
assert.deepEqual(parseMongoWriteCommand('db.projects.updateOne({"_id":"1"},{"$set":{"name":"next"}})'), {
kind: "update",
collection: "projects",
filter: '{"_id":"1"}',
update: '{"$set":{"name":"next"}}',
many: false,
});
assert.deepEqual(parseMongoWriteCommand('db.projects.deleteMany({"stale":true})'), {
kind: "delete",
collection: "projects",
filter: '{"stale":true}',
many: true,
});
assert.deepEqual(parseMongoWriteCommand('db.projects.createIndex({"email":1},{"unique":true,"name":"projects_email_unique"})'), {
kind: "createIndex",
collection: "projects",
keys: '{"email":1}',
options: '{"unique":true,"name":"projects_email_unique"}',
});
assert.deepEqual(parseMongoWriteCommand('db.projects.dropIndex("projects_email_unique")'), {
kind: "dropIndex",
collection: "projects",
index: '"projects_email_unique"',
});
assert.deepEqual(parseMongoWriteCommand("db.projects.dropIndexes()"), {
kind: "dropIndexes",
collection: "projects",
});
assert.deepEqual(parseMongoWriteCommand('db.projects.dropIndexes({"email":1})'), {
kind: "dropIndexes",
collection: "projects",
indexes: '{"email":1}',
});
assert.deepEqual(parseMongoWriteCommand('db.projects.dropIndexes(["a_1","b_1"])'), {
kind: "dropIndexes",
collection: "projects",
indexes: '["a_1","b_1"]',
});
assert.deepEqual(parseMongoWriteCommand("db.projects.drop()"), {
kind: "dropCollection",
collection: "projects",
});
assert.deepEqual(parseMongoWriteCommand('db.getCollection("audit.logs").drop();'), {
kind: "dropCollection",
collection: "audit.logs",
});
});
test("parseMongoWriteCommand accepts legacy insert commands", () => {
assert.deepEqual(parseMongoWriteCommand('db.getCollection("accounting_reconciliations").insert({"accountId":999,"status":"done"});'), {
kind: "insert",
collection: "accounting_reconciliations",
docsJson: '{"accountId":999,"status":"done"}',
});
assert.deepEqual(parseMongoWriteCommand('db.projects.insert([{"name":"first"},{"name":"second"}])'), {
kind: "insert",
collection: "projects",
docsJson: '[{"name":"first"},{"name":"second"}]',
});
assert.deepEqual(parseMongoWriteCommand('db.projects.insertMany([{"name":"first"},{"name":"second"}])'), {
kind: "insert",
collection: "projects",
docsJson: '[{"name":"first"},{"name":"second"}]',
});
assert.equal(parseMongoWriteCommand('db.projects.insert({"name":"demo"},{"writeConcern":{"w":1}})'), null);
assert.equal(parseMongoWriteCommand("db.projects.insert()"), null);
});
test("parseMongoWriteCommand rejects invalid dropIndex and dropIndexes commands", () => {
assert.equal(parseMongoWriteCommand("db.projects.dropIndex()"), null);
assert.equal(parseMongoWriteCommand('db.projects.dropIndex("*")'), null);
assert.equal(parseMongoWriteCommand('db.projects.dropIndex(["a_1"])'), null);
assert.equal(parseMongoWriteCommand('db.projects.dropIndexes([{"email":1}])'), null);
assert.equal(parseMongoWriteCommand("db.projects.drop({ writeConcern: 1 })"), null);
});
test("mongodb executeQuery blocks writes when writes are explicitly disabled", async () => {
const oldAllowWrites = process.env.DBX_MCP_ALLOW_WRITES;
process.env.DBX_MCP_ALLOW_WRITES = "0";
await assert.rejects(
executeQuery(
{
id: "mongo",
name: "mongo",
db_type: "mongodb",
host: "127.0.0.1",
port: 27017,
username: "",
password: "",
database: "app",
ssh_enabled: false,
ssl: false,
},
'db.projects.insertOne({"name":"demo"})',
),
/read-only/i,
);
if (oldAllowWrites === undefined) delete process.env.DBX_MCP_ALLOW_WRITES;
else process.env.DBX_MCP_ALLOW_WRITES = oldAllowWrites;
});
test("mongodb executeQuery treats createIndex as a write when writes are explicitly disabled", async () => {
const oldAllowWrites = process.env.DBX_MCP_ALLOW_WRITES;
process.env.DBX_MCP_ALLOW_WRITES = "0";
await assert.rejects(
executeQuery(
{
id: "mongo",
name: "mongo",
db_type: "mongodb",
host: "127.0.0.1",
port: 27017,
username: "",
password: "",
database: "app",
ssh_enabled: false,
ssl: false,
},
'db.projects.createIndex({"email":1})',
),
/read-only/i,
);
if (oldAllowWrites === undefined) delete process.env.DBX_MCP_ALLOW_WRITES;
else process.env.DBX_MCP_ALLOW_WRITES = oldAllowWrites;
});
test("mongodb executeQuery treats dropIndex as a write when writes are explicitly disabled", async () => {
const oldAllowWrites = process.env.DBX_MCP_ALLOW_WRITES;
process.env.DBX_MCP_ALLOW_WRITES = "0";
await assert.rejects(
executeQuery(
{
id: "mongo",
name: "mongo",
db_type: "mongodb",
host: "127.0.0.1",
port: 27017,
username: "",
password: "",
database: "app",
ssh_enabled: false,
ssl: false,
},
'db.projects.dropIndex("projects_email_unique")',
),
/read-only/i,
);
if (oldAllowWrites === undefined) delete process.env.DBX_MCP_ALLOW_WRITES;
else process.env.DBX_MCP_ALLOW_WRITES = oldAllowWrites;
});
test("mongodb executeQuery blocks dangerous dropIndexes shapes until dangerous SQL is enabled", async () => {
const oldAllowWrites = process.env.DBX_MCP_ALLOW_WRITES;
const oldAllowDangerous = process.env.DBX_MCP_ALLOW_DANGEROUS_SQL;
process.env.DBX_MCP_ALLOW_WRITES = "1";
delete process.env.DBX_MCP_ALLOW_DANGEROUS_SQL;
const config = {
id: "mongo",
name: "mongo",
db_type: "mongodb",
host: "127.0.0.1",
port: 27017,
username: "",
password: "",
database: "app",
ssh_enabled: false,
ssl: false,
} as const;
await assert.rejects(executeQuery(config, "db.projects.dropIndexes()"), /DBX_MCP_ALLOW_DANGEROUS_SQL=1/);
await assert.rejects(executeQuery(config, 'db.projects.dropIndexes("*")'), /DBX_MCP_ALLOW_DANGEROUS_SQL=1/);
await assert.rejects(executeQuery(config, 'db.projects.dropIndexes(["a_1","b_1"])'), /DBX_MCP_ALLOW_DANGEROUS_SQL=1/);
await assert.rejects(executeQuery(config, "db.projects.drop()"), /DBX_MCP_ALLOW_DANGEROUS_SQL=1/);
if (oldAllowWrites === undefined) delete process.env.DBX_MCP_ALLOW_WRITES;
else process.env.DBX_MCP_ALLOW_WRITES = oldAllowWrites;
if (oldAllowDangerous === undefined) delete process.env.DBX_MCP_ALLOW_DANGEROUS_SQL;
else process.env.DBX_MCP_ALLOW_DANGEROUS_SQL = oldAllowDangerous;
});
test("mongoDocumentsToQueryResult turns documents into rows", () => {
assert.deepEqual(
mongoDocumentsToQueryResult(
[
{ _id: "1", nested: { ok: true } },
{ _id: "2", name: "demo" },
],
2,
),
{
columns: ["_id", "nested", "name"],
rows: [
{ _id: "1", nested: '{"ok":true}', name: undefined },
{ _id: "2", nested: undefined, name: "demo" },
],
row_count: 2,
},
);
});
test("inferMongoColumns marks _id as primary and reports observed types", () => {
assert.deepEqual(
inferMongoColumns([
{ _id: "1", active: true },
{ _id: "2", active: null },
]),
[
{
name: "_id",
data_type: "string",
is_nullable: false,
column_default: null,
is_primary_key: true,
comment: null,
},
{
name: "active",
data_type: "boolean | null",
is_nullable: true,
column_default: null,
is_primary_key: false,
comment: null,
},
],
);
});

View File

@ -1,34 +0,0 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import type { ConnectionConfig } from "../src/connections.js";
import { buildConnectionUrl } from "../src/database.js";
function mysqlConfig(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
return {
id: "mysql-test",
name: "mysql",
db_type: "mysql",
host: "mysql.example.com",
port: 3306,
username: "root",
password: "secret",
database: "app",
ssl: false,
...overrides,
};
}
test("mysql MCP URL does not enable TLS by default", () => {
const url = buildConnectionUrl(mysqlConfig(), { host: "mysql.example.com", port: 3306 });
assert.equal(url, "mysql://root:secret@mysql.example.com:3306/app");
});
test("mysql MCP URL preserves explicit preferred TLS mode", () => {
const url = buildConnectionUrl(mysqlConfig({ url_params: "ssl-mode=preferred" }), {
host: "mysql.example.com",
port: 3306,
});
assert.equal(url, "mysql://root:secret@mysql.example.com:3306/app?ssl-mode=preferred");
});

View File

@ -1,11 +0,0 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { appDataDirFromInputs } from "../src/paths.js";
test("default app data dir matches Tauri local data dir on Linux", () => {
assert.equal(appDataDirFromInputs({ platform: "linux", home: "/home/dbx" }), "/home/dbx/.local/share/com.dbx.app");
});
test("DBX_DATA_DIR overrides the default app data dir", () => {
assert.equal(appDataDirFromInputs({ platform: "linux", home: "/home/dbx", envDataDir: "/tmp/dbx-data" }), "/tmp/dbx-data");
});

View File

@ -1,140 +0,0 @@
import assert from "node:assert/strict";
import { afterEach, test, vi } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ConnectionConfig } from "../src/connections.js";
const pgMocks = vi.hoisted(() => ({
poolConfigs: [] as Array<Record<string, unknown>>,
query: vi.fn(),
end: vi.fn().mockResolvedValue(undefined),
on: vi.fn(),
}));
vi.mock("pg", () => ({
default: {
Pool: vi.fn(function MockPool(config: Record<string, unknown>) {
pgMocks.poolConfigs.push(config);
return {
query: (sql: string, params?: unknown[]) => pgMocks.query(config, sql, params),
end: pgMocks.end,
on: pgMocks.on,
};
}),
},
}));
import { closeDatabaseResources, executeQuery } from "../src/database.js";
function postgresConfig(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
return {
id: `pg-ssl-${Math.random()}`,
name: "pg-ssl",
db_type: "postgres",
host: "pg.example.com",
port: 5432,
username: "postgres",
password: "secret",
database: "app",
ssl: false,
...overrides,
};
}
function successfulQuery() {
return { rows: [{ ok: true }], fields: [{ name: "ok" }] };
}
afterEach(async () => {
await closeDatabaseResources();
pgMocks.poolConfigs.length = 0;
pgMocks.query.mockReset();
pgMocks.end.mockClear();
pgMocks.on.mockClear();
});
test("explicit PostgreSQL prefer mode tries TLS and falls back only when SSL is unsupported", async () => {
pgMocks.query.mockImplementation((config: { ssl?: unknown }) => {
if (config.ssl === false) return Promise.resolve(successfulQuery());
return Promise.reject(new Error("The server does not support SSL connections"));
});
const result = await executeQuery(postgresConfig({ url_params: "sslmode=prefer" }), "select true as ok");
assert.deepEqual(result.rows, [{ ok: true }]);
assert.equal(pgMocks.poolConfigs.length, 2);
assert.deepEqual(pgMocks.poolConfigs[0]?.ssl, { rejectUnauthorized: false });
assert.equal(pgMocks.poolConfigs[1]?.ssl, false);
assert.equal(pgMocks.query.mock.calls.length, 2);
});
test("explicit prefer does not downgrade on authentication or certificate errors", async () => {
pgMocks.query.mockRejectedValue(new Error('no pg_hba.conf entry for host "127.0.0.1", no encryption'));
await assert.rejects(() => executeQuery(postgresConfig({ url_params: "sslmode=prefer" }), "select 1"), /no pg_hba\.conf entry/);
assert.equal(pgMocks.poolConfigs.length, 1);
assert.deepEqual(pgMocks.poolConfigs[0]?.ssl, { rejectUnauthorized: false });
});
test("implicit PostgreSQL SSL mode remains disabled", async () => {
pgMocks.query.mockResolvedValue(successfulQuery());
await executeQuery(postgresConfig(), "select 1");
assert.equal(pgMocks.poolConfigs.length, 1);
assert.equal(pgMocks.poolConfigs[0]?.ssl, false);
});
test("explicit disable uses plaintext and never retries TLS negotiation failures", async () => {
pgMocks.query.mockRejectedValue(new Error("The server does not support SSL connections"));
await assert.rejects(() => executeQuery(postgresConfig({ url_params: "sslmode=disable" }), "select 1"), /does not support SSL/);
assert.equal(pgMocks.poolConfigs.length, 1);
assert.equal(pgMocks.poolConfigs[0]?.ssl, false);
assert.doesNotMatch(String(pgMocks.poolConfigs[0]?.connectionString), /sslmode=/);
});
test("require and verification modes never downgrade", async () => {
for (const mode of ["require", "verify-ca", "verify-full", "verify_identity"] as const) {
pgMocks.query.mockRejectedValueOnce(new Error("The server does not support SSL connections"));
await assert.rejects(() => executeQuery(postgresConfig({ url_params: `sslmode=${mode}` }), "select 1"), /does not support SSL/);
}
assert.equal(pgMocks.poolConfigs.length, 4);
assert.deepEqual(pgMocks.poolConfigs[0]?.ssl, { rejectUnauthorized: false });
assert.equal(typeof (pgMocks.poolConfigs[1]?.ssl as { checkServerIdentity?: unknown }).checkServerIdentity, "function");
assert.deepEqual(pgMocks.poolConfigs[2]?.ssl, {});
assert.deepEqual(pgMocks.poolConfigs[3]?.ssl, {});
});
test("PostgreSQL SSL files are loaded into the ssl object and removed from the connection string", async () => {
const directory = await mkdtemp(join(tmpdir(), "dbx-pg-ssl-"));
const caPath = join(directory, "ca.pem");
const certPath = join(directory, "client.pem");
const keyPath = join(directory, "client.key");
await Promise.all([writeFile(caPath, "ca"), writeFile(certPath, "cert"), writeFile(keyPath, "key")]);
pgMocks.query.mockResolvedValue(successfulQuery());
try {
await executeQuery(
postgresConfig({
url_params: `sslmode=verify-full&sslrootcert=${encodeURIComponent(caPath)}&sslcert=${encodeURIComponent(certPath)}&sslkey=${encodeURIComponent(keyPath)}&application_name=dbx`,
}),
"select 1",
);
} finally {
await rm(directory, { recursive: true, force: true });
}
const poolConfig = pgMocks.poolConfigs[0];
assert.equal(poolConfig?.connectionString, "postgres://postgres:secret@pg.example.com:5432/app?application_name=dbx");
assert.deepEqual(poolConfig?.ssl, {
ca: Buffer.from("ca"),
cert: Buffer.from("cert"),
key: Buffer.from("key"),
});
});

View File

@ -1,79 +0,0 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import type { ConnectionConfig } from "../src/connections.js";
import { buildConnectionUrl } from "../src/database.js";
function postgresConfig(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
return {
id: "pg-test",
name: "pg",
db_type: "postgres",
host: "pg.example.com",
port: 5432,
username: "postgres",
password: "secret",
database: "app",
ssl: false,
...overrides,
};
}
test("postgres MCP URL adds sslmode=require when TLS is enabled", () => {
const url = buildConnectionUrl(postgresConfig({ ssl: true }), { host: "pg.example.com", port: 5432 });
assert.equal(url, "postgres://postgres:secret@pg.example.com:5432/app?sslmode=require");
});
test("postgres MCP URL keeps explicit sslmode", () => {
const url = buildConnectionUrl(postgresConfig({ ssl: true, url_params: "sslmode=verify-full&application_name=dbx" }), {
host: "pg.example.com",
port: 5432,
});
assert.equal(url, "postgres://postgres:secret@pg.example.com:5432/app?sslmode=verify-full&application_name=dbx");
});
test("postgres MCP URL maps ssl-mode to sslmode", () => {
const url = buildConnectionUrl(postgresConfig({ url_params: "ssl-mode=required" }), {
host: "pg.example.com",
port: 5432,
});
assert.equal(url, "postgres://postgres:secret@pg.example.com:5432/app?sslmode=require");
});
test("postgres MCP URL normalizes direct sslmode aliases", () => {
const url = buildConnectionUrl(postgresConfig({ url_params: "sslmode=verify_identity" }), {
host: "pg.example.com",
port: 5432,
});
assert.equal(url, "postgres://postgres:secret@pg.example.com:5432/app?sslmode=verify-full");
});
test("postgres MCP URL drops MySQL-style TLS params", () => {
const url = buildConnectionUrl(
postgresConfig({
url_params: "ssl-mode=required&verify_ca=false&verify_identity=false&require_ssl=true&charset=utf8mb4&application_name=dbx",
}),
{ host: "pg.example.com", port: 5432 },
);
assert.equal(url, "postgres://postgres:secret@pg.example.com:5432/app?sslmode=require&application_name=dbx");
});
test("postgres MCP URL maps schema and timezone to options", () => {
const url = buildConnectionUrl(postgresConfig({ url_params: "schema=app&timezone=UTC" }), {
host: "pg.example.com",
port: 5432,
});
assert.equal(url, "postgres://postgres:secret@pg.example.com:5432/app?options=-c%20search_path%3Dapp%20-c%20TimeZone%3DUTC");
});
test("pooled URL builder rejects non-pooled direct types", () => {
assert.throws(
() => buildConnectionUrl(postgresConfig({ db_type: "sqlite", host: "/tmp/app.db", port: 0 }), { host: "/tmp/app.db", port: 0 }),
/Unsupported pooled connection type: sqlite/,
);
});

View File

@ -1,108 +0,0 @@
import { describe, expect, it } from "vitest";
import { readFileSync } from "node:fs";
import { assessProductionSql, isLikelyMongoMutation, isProductionDatabase } from "../src/production-safety.js";
import type { ConnectionConfig } from "../src/connections.js";
interface ProductionSafetyCorpusCase {
name: string;
dialect: ConnectionConfig["db_type"];
productionDatabases: string[];
activeDatabase: string;
sql: string;
active: boolean;
isMutation: boolean;
databases: string[];
}
const productionSafetyCorpus = JSON.parse(readFileSync(new URL("../../../tests/fixtures/production-safety-corpus.json", import.meta.url), "utf8")) as ProductionSafetyCorpusCase[];
function connection(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
return {
id: "conn-1",
name: "Operations",
db_type: "mysql",
host: "db.internal",
port: 3306,
username: "readonly",
password: "",
production_databases: ["prod_app"],
...overrides,
};
}
describe("production safety", () => {
it("keeps an explicit production connection in scope", () => {
expect(isProductionDatabase(connection({ is_production: true }), "scratch")).toBe(true);
});
it("detects a production write through USE and a qualified table name", () => {
expect(assessProductionSql("-- migrate\nUSE prod_app; /* delete old rows */ DELETE FROM users", connection(), "staging")).toMatchObject({ active: true, isMutation: true });
expect(assessProductionSql("DELETE FROM prod_app.orders", connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
expect(assessProductionSql("DROP DATABASE IF EXISTS prod_app", connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
});
it("detects production writes hidden behind parser-sensitive SQL forms", () => {
for (const sql of ["EXPLAIN ANALYZE DELETE FROM prod_app.users WHERE id = 1", "/*! DELETE FROM prod_app.users WHERE id = 1 */", "COPY prod_app.users FROM '/tmp/users.csv'", "SELECT * INTO prod_app.backup_users FROM users", "SELECT * FROM prod_app.users INTO OUTFILE '/tmp/users.csv'"]) {
expect(assessProductionSql(sql, connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
}
});
it("matches the shared SQL target safety corpus", () => {
for (const corpusCase of productionSafetyCorpus) {
const assessment = assessProductionSql(
corpusCase.sql,
connection({
db_type: corpusCase.dialect,
production_databases: corpusCase.productionDatabases,
}),
corpusCase.activeDatabase,
);
expect(
{
active: assessment.active,
isMutation: assessment.isMutation,
databases: assessment.databases,
},
corpusCase.name,
).toEqual({
active: corpusCase.active,
isMutation: corpusCase.isMutation,
databases: corpusCase.databases,
});
}
});
it("detects qualified procedure calls and privilege targets", () => {
for (const sql of ["CALL prod_app.purge_users()", "CALL `prod_app`.`purge_users`()", "GRANT ALL ON prod_app.* TO 'u'@'%'", "GRANT EXECUTE ON PROCEDURE prod_app.purge_users TO 'u'@'%'"]) {
expect(assessProductionSql(sql, connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
}
});
it("allows resolved non-production procedure and privilege targets", () => {
expect(assessProductionSql("CALL staging.purge_users()", connection(), "staging")).toMatchObject({ active: false, isMutation: true });
expect(assessProductionSql("GRANT ALL ON staging.* TO 'u'@'%'", connection(), "staging")).toMatchObject({ active: false, isMutation: true });
});
it("conservatively confirms ambiguous production targets", () => {
for (const sql of ["CALL purge_users()", "GRANT PROCESS ON *.* TO 'u'@'%'", "GRANT ALL ON users TO 'u'@'%'", "CREATE USER 'u'@'%'"]) {
expect(assessProductionSql(sql, connection(), "staging")).toMatchObject({ active: true, isMutation: true, databases: ["prod_app"] });
}
});
it("does not treat read-only qualified references as write targets", () => {
expect(assessProductionSql("SELECT * FROM prod_app.orders; DELETE FROM staging.users WHERE id = 1", connection(), "staging")).toMatchObject({ active: false, isMutation: true });
});
it("treats unrecognized SQL as a mutation when production is selected", () => {
expect(assessProductionSql("MAINTAIN UNKNOWN THING", connection(), "prod_app")).toMatchObject({ active: true, isMutation: true });
});
it("does not treat a read as a production write", () => {
expect(assessProductionSql("SELECT * FROM prod_app.orders", connection(), "staging")).toMatchObject({ active: false, isMutation: false });
});
it("recognizes Mongo write commands before MCP forwards them", () => {
expect(isLikelyMongoMutation("db.orders.updateOne({_id: 1}, {$set: {status: 'paid'}})")).toBe(true);
expect(isLikelyMongoMutation("db.orders.find({status: 'paid'})")).toBe(false);
});
});

View File

@ -1,37 +0,0 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { classifyRedisCommand, evaluateRedisCommandSafety, firstRedisCommandToken, parseRedisCommandArgv } from "../src/redis-command.js";
test("firstRedisCommandToken normalizes the command name", () => {
assert.equal(firstRedisCommandToken(" get session:1"), "GET");
});
test("parseRedisCommandArgv handles quoted values and escapes", () => {
assert.deepEqual(parseRedisCommandArgv('SET session:1 "hello world"'), ["SET", "session:1", "hello world"]);
assert.deepEqual(parseRedisCommandArgv("SET key line\\nnext;"), ["SET", "key", "line\nnext"]);
assert.throws(() => parseRedisCommandArgv('GET "unterminated'), /unterminated quote/);
});
test("classifyRedisCommand mirrors DBX redis command safety classes", () => {
assert.equal(classifyRedisCommand("GET session:1"), "allowed");
assert.equal(classifyRedisCommand("SET session:1 value"), "write");
assert.equal(classifyRedisCommand("DEL session:1"), "confirm");
assert.equal(classifyRedisCommand("KEYS *"), "blocked");
});
test("evaluateRedisCommandSafety blocks write commands when writes are disabled", () => {
const decision = evaluateRedisCommandSafety("SET session:1 value", { allowWrites: false });
assert.equal(decision.allowed, false);
assert.match(decision.reason ?? "", /read-only/i);
});
test("evaluateRedisCommandSafety requires dangerous mode for blocked commands", () => {
const blocked = evaluateRedisCommandSafety("KEYS *", { allowWrites: true, allowDangerous: false });
const allowed = evaluateRedisCommandSafety("KEYS *", { allowWrites: true, allowDangerous: true });
assert.equal(blocked.allowed, false);
assert.match(blocked.reason ?? "", /dangerous/i);
assert.equal(allowed.allowed, true);
assert.equal(allowed.skipSafetyCheck, true);
});

View File

@ -1,145 +0,0 @@
import assert from "node:assert/strict";
import { createServer, type Socket } from "node:net";
import { test } from "vitest";
import type { ConnectionConfig } from "../src/connections.js";
import { executeRedisCommand } from "../src/database.js";
type RedisRequest = { command: string; args: string[] };
function redisConnection(port: number): ConnectionConfig {
return {
id: "redis-direct",
name: "redis-direct",
db_type: "redis",
host: "127.0.0.1",
port,
username: "",
password: "",
database: "0",
redis_connection_mode: "standalone",
ssh_enabled: false,
ssl: false,
};
}
async function withRedisServer<T>(handler: (request: RedisRequest) => string, fn: (port: number, seen: RedisRequest[]) => Promise<T>): Promise<T> {
const seen: RedisRequest[] = [];
const server = createServer((socket) => {
let buffer = Buffer.alloc(0);
socket.on("data", (chunk) => {
buffer = Buffer.concat([buffer, chunk]);
while (buffer.length > 0) {
const parsed = parseRespRequest(buffer);
if (!parsed) break;
buffer = buffer.subarray(parsed.bytes);
seen.push(parsed.request);
socket.write(handler(parsed.request));
}
});
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
try {
const address = server.address();
assert.equal(typeof address, "object");
assert(address && "port" in address);
return await fn(address.port, seen);
} finally {
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
}
}
function parseRespRequest(buffer: Buffer): { request: RedisRequest; bytes: number } | undefined {
let offset = 0;
const firstLine = readLine(buffer, offset);
if (!firstLine) return undefined;
offset = firstLine.next;
const count = Number(firstLine.line.slice(1));
const parts: string[] = [];
for (let i = 0; i < count; i++) {
const lengthLine = readLine(buffer, offset);
if (!lengthLine) return undefined;
offset = lengthLine.next;
const length = Number(lengthLine.line.slice(1));
if (buffer.length < offset + length + 2) return undefined;
parts.push(buffer.subarray(offset, offset + length).toString("utf8"));
offset += length + 2;
}
return {
request: {
command: parts[0]?.toUpperCase() ?? "",
args: parts.slice(1),
},
bytes: offset,
};
}
function readLine(buffer: Buffer, offset: number): { line: string; next: number } | undefined {
const end = buffer.indexOf("\r\n", offset);
if (end < 0) return undefined;
return { line: buffer.subarray(offset, end).toString("utf8"), next: end + 2 };
}
function bulk(value: string): string {
return `$${Buffer.byteLength(value)}\r\n${value}\r\n`;
}
test("executeRedisCommand runs standalone redis commands without the DBX bridge", async () => {
await withRedisServer(
(request) => {
if (request.command === "CLIENT") return "+OK\r\n";
if (request.command === "SELECT") return "+OK\r\n";
assert.deepEqual(request, { command: "GET", args: ["session:1"] });
return bulk("value-1");
},
async (port, seen) => {
const result = await executeRedisCommand(redisConnection(port), 2, "GET session:1");
assert.deepEqual(result, { command: "GET", safety: "allowed", value: "value-1" });
const dataCommands = seen.filter((request) => request.command !== "CLIENT");
assert.deepEqual(
dataCommands.map((request) => request.command),
["SELECT", "GET"],
);
assert.deepEqual(dataCommands[0].args, ["2"]);
},
);
});
test("executeRedisCommand parses quoted arguments and JSON bulk replies", async () => {
await withRedisServer(
(request) => {
if (request.command === "CLIENT") return "+OK\r\n";
if (request.command === "SET") {
assert.deepEqual(request.args, ["session:1", "hello world"]);
return "+OK\r\n";
}
return bulk('{"ok":true}');
},
async (port) => {
const set = await executeRedisCommand(redisConnection(port), 0, 'SET session:1 "hello world"');
const get = await executeRedisCommand(redisConnection(port), 0, "GET session:1");
assert.deepEqual(set, { command: "SET", safety: "write", value: "OK" });
assert.deepEqual(get, { command: "GET", safety: "allowed", value: { ok: true } });
},
);
});
test("executeRedisCommand keeps blocked redis commands behind skipSafetyCheck", async () => {
await withRedisServer(
(request) => {
if (request.command === "CLIENT") return "+OK\r\n";
return bulk('["session:1"]');
},
async (port) => {
await assert.rejects(() => executeRedisCommand(redisConnection(port), 0, "KEYS *"), /blocked for safety/);
const result = await executeRedisCommand(redisConnection(port), 0, "KEYS *", { skipSafetyCheck: true });
assert.deepEqual(result, { command: "KEYS", safety: "blocked", value: ["session:1"] });
},
);
});

View File

@ -1,131 +0,0 @@
import assert from "node:assert/strict";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { test } from "vitest";
import type { ConnectionConfig } from "../src/connections.js";
import { describeTable, executeQuery, listTables } from "../src/database.js";
function rqliteConfig(port: number): ConnectionConfig {
return {
id: "rqlite-test",
name: "local-rqlite",
db_type: "rqlite",
host: "127.0.0.1",
port,
username: "dbx",
password: "secret",
database: "main",
ssh_enabled: false,
ssl: false,
};
}
async function withRqliteServer(handler: (req: IncomingMessage, res: ServerResponse, body: string) => void) {
const server = createServer((req, res) => {
let body = "";
req.setEncoding("utf8");
req.on("data", (chunk) => {
body += chunk;
});
req.on("end", () => handler(req, res, body));
});
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
const address = server.address();
assert.ok(address && typeof address === "object");
return {
port: address.port,
close: () => new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))),
};
}
function json(res: ServerResponse, body: unknown) {
res.writeHead(200, { "content-type": "application/json" });
res.end(JSON.stringify(body));
}
test("executes rqlite query through the HTTP API", async () => {
const server = await withRqliteServer((req, res, body) => {
assert.equal(req.url, "/db/query");
assert.equal(req.headers.authorization, "Basic ZGJ4OnNlY3JldA==");
assert.deepEqual(JSON.parse(body), ["select id, name from users"]);
json(res, {
results: [
{
columns: ["id", "name"],
values: [
[1, "Ada"],
[2, "Linus"],
],
},
],
});
});
try {
const result = await executeQuery(rqliteConfig(server.port), "select id, name from users", { maxRows: 1 });
assert.deepEqual(result, { columns: ["id", "name"], rows: [{ id: 1, name: "Ada" }], row_count: 1 });
} finally {
await server.close();
}
});
test("lists rqlite tables and describes columns", async () => {
const server = await withRqliteServer((_req, res, body) => {
const sql = JSON.parse(body)[0] as string;
if (sql.includes("sqlite_master")) {
json(res, {
results: [
{
columns: ["name", "type"],
values: [
["users", "table"],
["active_users", "view"],
],
},
],
});
return;
}
if (sql.includes("PRAGMA table_info")) {
json(res, {
results: [
{
columns: ["cid", "name", "type", "notnull", "dflt_value", "pk"],
values: [
[0, "id", "INTEGER", 1, null, 1],
[1, "name", "TEXT", 0, "'unknown'", 0],
],
},
],
});
return;
}
json(res, { results: [{ columns: [], values: [] }] });
});
try {
assert.deepEqual(await listTables(rqliteConfig(server.port)), [
{ name: "users", type: "table" },
{ name: "active_users", type: "view" },
]);
assert.deepEqual(await describeTable(rqliteConfig(server.port), "users"), [
{
name: "id",
data_type: "INTEGER",
is_nullable: false,
column_default: null,
is_primary_key: true,
comment: null,
},
{
name: "name",
data_type: "TEXT",
is_nullable: true,
column_default: "'unknown'",
is_primary_key: false,
comment: null,
},
]);
} finally {
await server.close();
}
});

View File

@ -1,87 +0,0 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import type { ConnectionConfig } from "../src/connections.js";
import type { ColumnInfo, TableInfo } from "../src/database.js";
import { buildSchemaContext, formatSchemaContext } from "../src/schema-context.js";
const config: ConnectionConfig = {
id: "pg",
name: "analytics",
db_type: "postgres",
host: "localhost",
port: 5432,
username: "app",
password: "",
database: "warehouse",
ssh_enabled: false,
ssl: false,
};
const tableRows: TableInfo[] = [
{ name: "users", type: "BASE TABLE" },
{ name: "orders", type: "BASE TABLE" },
{ name: "events", type: "BASE TABLE" },
];
const columns: Record<string, ColumnInfo[]> = {
users: [
{ name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, comment: null },
{ name: "email", data_type: "text", is_nullable: false, column_default: null, is_primary_key: false, comment: "Login email" },
],
orders: [
{ name: "id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: true, comment: null },
{ name: "user_id", data_type: "integer", is_nullable: false, column_default: null, is_primary_key: false, comment: null },
],
};
test("builds schema context for requested tables", async () => {
const context = await buildSchemaContext(
{
listTables: async () => tableRows,
describeTable: async (_config, table) => columns[table] ?? [],
},
config,
{ tables: ["users", "orders"], maxTables: 5 },
);
assert.equal(context.connection, "analytics");
assert.deepEqual(
context.tables.map((table) => table.name),
["users", "orders"],
);
assert.equal(context.tables[0].columns[0].is_primary_key, true);
});
test("limits schema context when no table list is provided", async () => {
const context = await buildSchemaContext(
{
listTables: async () => tableRows,
describeTable: async (_config, table) => columns[table] ?? [],
},
config,
{ maxTables: 2 },
);
assert.deepEqual(
context.tables.map((table) => table.name),
["users", "orders"],
);
});
test("formats schema context as compact markdown", async () => {
const context = await buildSchemaContext(
{
listTables: async () => tableRows,
describeTable: async (_config, table) => columns[table] ?? [],
},
config,
{ tables: ["users"] },
);
const markdown = formatSchemaContext(context);
assert.match(markdown, /Connection: analytics/);
assert.match(markdown, /## users/);
assert.match(markdown, /id integer NOT NULL PK/);
assert.match(markdown, /email text NOT NULL -- Login email/);
});

View File

@ -1,107 +0,0 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { logSqlDiagnostic, redactSqlForDiagnostics, sqlDiagnosticsEnabled } from "../src/sql-diagnostics.js";
test("SQL diagnostics are disabled unless explicitly enabled", () => {
assert.equal(sqlDiagnosticsEnabled({}), false);
assert.equal(sqlDiagnosticsEnabled({ DBX_SQL_DEBUG: "0" }), false);
assert.equal(sqlDiagnosticsEnabled({ DBX_SQL_DEBUG: "1" }), true);
assert.equal(sqlDiagnosticsEnabled({ DBX_MCP_DEBUG_SQL: "true" }), true);
});
test("redacts sensitive literals and bounds large SQL diagnostics", () => {
const sql = `select * from users where password = 'secret-123' and token="tok-456" and api_key=plain ${"x".repeat(900)}`;
const redacted = redactSqlForDiagnostics(sql);
assert.doesNotMatch(redacted, /secret-123|tok-456|api_key=plain/);
assert.match(redacted, /\[REDACTED\]/);
assert.match(redacted, /api_key=\[REDACTED\]/);
assert.match(redacted, /truncated/);
assert.ok(redacted.length < sql.length);
});
test("disabled SQL diagnostic logging does not write statements", () => {
const original = console.error;
const messages: unknown[][] = [];
console.error = (...args: unknown[]) => messages.push(args);
try {
logSqlDiagnostic("test", "select 'secret-123'", {}, {});
} finally {
console.error = original;
}
assert.equal(messages.length, 0);
});
test("enabled SQL diagnostic logging emits redacted statements only", () => {
const original = console.error;
const messages: unknown[][] = [];
console.error = (...args: unknown[]) => messages.push(args);
try {
logSqlDiagnostic("test", "select 'secret-123' as password", {}, { DBX_SQL_DEBUG: "1" });
} finally {
console.error = original;
}
assert.equal(messages.length, 1);
const rendered = messages.flat().join(" ");
assert.doesNotMatch(rendered, /secret-123/);
assert.match(rendered, /\[REDACTED\]/);
});
test("dollar-quoted strings are redacted", () => {
const redacted = redactSqlForDiagnostics("select $$secret$$");
assert.doesNotMatch(redacted, /secret/);
assert.match(redacted, /\[REDACTED\]/);
});
test("space-separated sensitive assignments are redacted", () => {
const redacted = redactSqlForDiagnostics("select * from t where password = mysecret");
assert.doesNotMatch(redacted, /mysecret/);
assert.match(redacted, /\[REDACTED\]/);
});
test("postgres positional parameters are not treated as dollar quotes ($1, $2, ...)", () => {
const redacted = redactSqlForDiagnostics("select * from t where id = $1 and name = 'alice'");
assert.match(redacted, /\$1\b/);
assert.doesNotMatch(redacted, /alice/);
});
test("multiple postgres positional parameters all survive redaction", () => {
const redacted = redactSqlForDiagnostics("select $1, $2, $3, $42 from t");
assert.match(redacted, /\$1\b/);
assert.match(redacted, /\$2\b/);
assert.match(redacted, /\$3\b/);
assert.match(redacted, /\$42\b/);
});
test("empty-tag dollar quote $$secret$$ is redacted", () => {
const redacted = redactSqlForDiagnostics("select $$secret$$ from t");
assert.match(redacted, /\$\$\[REDACTED\]\$\$/);
assert.doesNotMatch(redacted, /secret/);
});
test("named-tag dollar quote $tag$hello$tag$ is redacted", () => {
const redacted = redactSqlForDiagnostics("select $tag$hello$tag$ from t");
assert.match(redacted, /\$\[REDACTED\]\$/);
assert.doesNotMatch(redacted, /hello/);
});
test("lone trailing dollar sign does not throw and passes through", () => {
const redacted = redactSqlForDiagnostics("select 1 $");
assert.match(redacted, /\$/);
});
test("bounds redaction before scanning an unclosed literal", () => {
const redacted = redactSqlForDiagnostics(`select '${"secret-".repeat(1000)}`, 32);
assert.doesNotMatch(redacted, /secret-/);
assert.match(redacted, /\[REDACTED\]/);
assert.match(redacted, /truncated/);
});
test("does not leak a sensitive value cut at the diagnostic boundary", () => {
const redacted = redactSqlForDiagnostics(`password = ${"secret-token".repeat(1000)}`, 24);
assert.doesNotMatch(redacted, /secret-token|secret-/);
assert.match(redacted, /password = \[REDACTED\]/);
assert.match(redacted, /truncated/);
});

View File

@ -1,187 +0,0 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import { evaluateSqlSafety, splitSqlStatements, sqlSafetyFromEnv } from "../src/sql-safety.js";
import { supportsHashLineComments } from "../src/sql-risk.js";
test("allows read-only SQL by default", () => {
const decision = evaluateSqlSafety("select * from users limit 5");
assert.equal(decision.allowed, true);
});
test("allows read-only EXPLAIN without ANALYZE", () => {
const decision = evaluateSqlSafety("EXPLAIN SELECT * FROM users");
assert.equal(decision.allowed, true);
});
test("allows non-dangerous write SQL by default when scoped", () => {
const decision = evaluateSqlSafety("update users set role = 'admin' where id = 1", sqlSafetyFromEnv({}));
assert.equal(decision.allowed, true);
});
test("blocks dangerous SQL even when writes are enabled", () => {
const decision = evaluateSqlSafety("drop table users", { allowWrites: true });
assert.equal(decision.allowed, false);
assert.match(decision.reason ?? "", /dangerous/i);
});
test("blocks update without where when writes are enabled", () => {
const decision = evaluateSqlSafety("update users set disabled = true", { allowWrites: true });
assert.equal(decision.allowed, false);
assert.match(decision.reason ?? "", /WHERE/i);
});
test("blocks writes that do not start with a write keyword in read-only mode", () => {
for (const sql of [
"EXPLAIN ANALYZE DELETE FROM users WHERE id = 1",
"/*! DELETE FROM users WHERE id = 1 */",
"COPY users FROM '/tmp/users.csv'",
"SELECT * INTO backup_users FROM users",
"SELECT * FROM users INTO OUTFILE '/tmp/users.csv'",
]) {
const decision = evaluateSqlSafety(sql);
assert.equal(decision.allowed, false, sql);
assert.match(decision.reason ?? "", /read-only|blocked/i);
}
});
test("blocks unrecognized SQL unless dangerous SQL is explicitly enabled", () => {
const decision = evaluateSqlSafety("MAINTAIN UNKNOWN THING", { allowWrites: true });
assert.equal(decision.allowed, false);
assert.match(decision.reason ?? "", /unrecognized/i);
});
test("blocks multiple SQL statements unless explicitly allowed", () => {
const decision = evaluateSqlSafety("select 1; select 2");
assert.equal(decision.allowed, false);
assert.match(decision.reason ?? "", /Only one SQL statement/);
});
test("allows multiple read-only SQL statements when enabled", () => {
const decision = evaluateSqlSafety("select 1; show tables", { allowMultipleStatements: true });
assert.equal(decision.allowed, true);
});
test("checks every statement in a multi-statement SQL string", () => {
const decision = evaluateSqlSafety("select 1; delete from users", {
allowMultipleStatements: true,
allowWrites: true,
});
assert.equal(decision.allowed, false);
assert.match(decision.reason ?? "", /Statement 2/i);
assert.match(decision.reason ?? "", /WHERE/i);
});
test("splits statements without altering SQL literals or comments", () => {
const sql = "SELECT 'a;b' AS value, ''abc'' AS quoted; -- keep comment\nSELECT $$c;d$$ AS dollar;";
assert.deepEqual(splitSqlStatements(sql), [
"SELECT 'a;b' AS value, ''abc'' AS quoted",
"-- keep comment\nSELECT $$c;d$$ AS dollar",
]);
});
test("keeps tagged dollar quotes and quoted identifiers intact", () => {
const sql = 'SELECT $body$begin; end$body$ AS body, "semi;colon" AS "quoted;column"; SELECT 2;';
assert.deepEqual(splitSqlStatements(sql), [
'SELECT $body$begin; end$body$ AS body, "semi;colon" AS "quoted;column"',
"SELECT 2",
]);
});
test("sqlSafetyFromEnv allows writes by default but keeps dangerous SQL blocked", () => {
const options = sqlSafetyFromEnv({});
assert.equal(options.allowWrites, true);
assert.equal(options.allowDangerous, false);
});
test("sqlSafetyFromEnv supports explicitly disabling writes", () => {
const options = sqlSafetyFromEnv({ DBX_MCP_ALLOW_WRITES: "0" } as NodeJS.ProcessEnv);
assert.equal(options.allowWrites, false);
assert.equal(options.allowDangerous, false);
});
// --- Dialect-aware `#` comment handling ---
test("supportsHashLineComments matches Rust mysql-compatible dialect set", () => {
for (const dbType of ["mysql", "doris", "starrocks", "manticoresearch", "goldendb"]) {
assert.equal(supportsHashLineComments(dbType), true, dbType);
}
for (const dbType of ["postgres", "sqlite", "sqlserver", "oracle", "duckdb", "bigquery", "redshift", ""]) {
assert.equal(supportsHashLineComments(dbType), false, dbType);
}
assert.equal(supportsHashLineComments(undefined), false);
});
test("splitSqlStatements splits PG `#` operator correctly (hashLineComments omitted/default)", () => {
assert.deepEqual(splitSqlStatements("SELECT a # b; SELECT 2"), ["SELECT a # b", "SELECT 2"]);
});
test("splitSqlStatements splits PG `#` operator correctly (hashLineComments: false)", () => {
assert.deepEqual(splitSqlStatements("SELECT a # b; SELECT 2", { hashLineComments: false }), [
"SELECT a # b",
"SELECT 2",
]);
});
test("splitSqlStatements treats `#` as comment with hashLineComments: true (MySQL)", () => {
// With hashLineComments: true, the `;` inside the `#` comment must NOT split.
// The comment text is preserved in the output (splitter only delimits on `;`, it doesn't strip).
assert.deepEqual(
splitSqlStatements("SELECT 1; # trailing ; comment\nSELECT 2", { hashLineComments: true }),
["SELECT 1", "# trailing ; comment\nSELECT 2"],
);
});
test("splitSqlStatements preserves JSONB operator text verbatim", () => {
const result = splitSqlStatements("SELECT data #>> '{a,b}' FROM t");
assert.equal(result.length, 1);
assert.equal(result[0], "SELECT data #>> '{a,b}' FROM t");
});
test("splitSqlStatements handles `#` as operator mid-statement (PG)", () => {
assert.deepEqual(splitSqlStatements("SELECT 1 # 2; DELETE FROM t"), [
"SELECT 1 # 2",
"DELETE FROM t",
]);
});
test("evaluateSqlSafety blocks PG injection that bypasses # as comment (regression)", () => {
// Before fix: # would strip "2; DELETE FROM t" as comment, classify as read-only.
// After fix: # is treated as an operator, so DELETE FROM t is seen as a second write statement.
const decision = evaluateSqlSafety("SELECT 1 # 2; DELETE FROM t", {
allowWrites: false,
allowMultipleStatements: true,
});
assert.equal(decision.allowed, false);
assert.match(decision.reason ?? "", /read-only/i);
});
test("evaluateSqlSafety allows MySQL `#` comment with hashLineComments: true", () => {
const decision = evaluateSqlSafety("SELECT 1 # delete note", {
allowWrites: false,
allowMultipleStatements: true,
hashLineComments: true,
});
assert.equal(decision.allowed, true);
});
test("evaluateSqlSafety with hashLineComments: false still sees DELETE after `#` operator", () => {
const decision = evaluateSqlSafety("SELECT 1 # 2; DELETE FROM t", {
allowWrites: false,
allowMultipleStatements: true,
hashLineComments: false,
});
assert.equal(decision.allowed, false);
});

View File

@ -1,131 +0,0 @@
import assert from "node:assert/strict";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { test } from "vitest";
import Database from "better-sqlite3";
import type { ConnectionConfig } from "../src/connections.js";
import { describeTable, executeQuery, listTables } from "../src/database.js";
function sqliteConfig(path: string): ConnectionConfig {
return {
id: "sqlite-test",
name: "local-sqlite",
db_type: "sqlite",
host: path,
port: 0,
username: "",
password: "",
ssh_enabled: false,
ssl: false,
};
}
function mysqlSshConfig(): ConnectionConfig {
return {
id: "mysql-ssh-test",
name: "mysql-over-ssh",
db_type: "mysql",
host: "10.0.0.10",
port: 3306,
username: "root",
password: "secret",
ssl: false,
transport_layers: [
{
type: "ssh",
id: "bastion",
enabled: true,
host: "bastion.example.com",
port: 22,
user: "root",
password: "ssh-secret",
},
],
};
}
test("queries SQLite connections without the DBX bridge", async () => {
const dir = mkdtempSync(join(tmpdir(), "dbx-mcp-sqlite-"));
const path = join(dir, "app.db");
const db = new Database(path);
db.exec("create table users (id integer primary key, name text not null); insert into users (name) values ('Ada');");
db.close();
try {
const result = await executeQuery(sqliteConfig(path), "select id, name from users");
assert.deepEqual(result.columns, ["id", "name"]);
assert.deepEqual(result.rows, [{ id: 1, name: "Ada" }]);
assert.equal(result.row_count, 1);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("applies query row limits to SQLite connections", async () => {
const dir = mkdtempSync(join(tmpdir(), "dbx-mcp-sqlite-"));
const path = join(dir, "app.db");
const db = new Database(path);
db.exec("create table users (id integer primary key, name text not null); insert into users (name) values ('Ada'), ('Grace');");
db.close();
try {
const result = await executeQuery(sqliteConfig(path), "select id, name from users order by id", { maxRows: 1 });
assert.deepEqual(result.columns, ["id", "name"]);
assert.deepEqual(result.rows, [{ id: 1, name: "Ada" }]);
assert.equal(result.row_count, 1);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("lists and describes SQLite tables without the DBX bridge", async () => {
const dir = mkdtempSync(join(tmpdir(), "dbx-mcp-sqlite-"));
const path = join(dir, "app.db");
const db = new Database(path);
db.exec("create table users (id integer primary key, name text not null);");
db.close();
try {
const tables = await listTables(sqliteConfig(path));
const columns = await describeTable(sqliteConfig(path), "users");
assert.deepEqual(tables, [{ name: "users", type: "table" }]);
assert.deepEqual(
columns.map((column) => ({
name: column.name,
data_type: column.data_type,
is_nullable: column.is_nullable,
is_primary_key: column.is_primary_key,
})),
[
{ name: "id", data_type: "INTEGER", is_nullable: true, is_primary_key: true },
{ name: "name", data_type: "TEXT", is_nullable: false, is_primary_key: false },
],
);
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
test("routes SSH transport layer connections through the DBX bridge", async () => {
const dir = mkdtempSync(join(tmpdir(), "dbx-mcp-bridge-home-"));
const originalHome = process.env.HOME;
const originalDbxDataDir = process.env.DBX_DATA_DIR;
process.env.HOME = dir;
process.env.DBX_DATA_DIR = dir;
try {
await assert.rejects(() => executeQuery(mysqlSshConfig(), "select 1"), /DBX desktop app is not running/);
await assert.rejects(() => listTables(mysqlSshConfig()), /DBX desktop app is not running/);
await assert.rejects(() => describeTable(mysqlSshConfig(), "users"), /DBX desktop app is not running/);
} finally {
if (originalHome === undefined) delete process.env.HOME;
else process.env.HOME = originalHome;
if (originalDbxDataDir === undefined) delete process.env.DBX_DATA_DIR;
else process.env.DBX_DATA_DIR = originalDbxDataDir;
rmSync(dir, { recursive: true, force: true });
}
});

View File

@ -1,62 +0,0 @@
import assert from "node:assert/strict";
import { test } from "vitest";
import type { ConnectionConfig } from "../src/connections.js";
import { buildConnectionUrl } from "../src/database.js";
function starrocksConfig(overrides: Partial<ConnectionConfig> = {}): ConnectionConfig {
return {
id: "sr-1",
name: "starrocks",
db_type: "starrocks",
host: "fe-example.starrocks.aliyuncs.com",
port: 9030,
username: "admin",
password: "secret",
database: "analytics",
ssl: false,
...overrides,
};
}
test("starrocks omits tls params when ssl is disabled", () => {
const url = buildConnectionUrl(
starrocksConfig({
url_params: "ssl-mode=disabled&require_ssl=true&verify_ca=true&charset=utf8mb4",
}),
{ host: "fe-example.starrocks.aliyuncs.com", port: 9030 },
);
assert.equal(url, "mysql://admin:secret@fe-example.starrocks.aliyuncs.com:9030/analytics");
});
test("starrocks preserves tls params when ssl is enabled", () => {
const url = buildConnectionUrl(
starrocksConfig({
ssl: true,
url_params: "verify_ca=true&verify_identity=false",
}),
{ host: "fe-example.starrocks.aliyuncs.com", port: 9030 },
);
assert.equal(
url,
"mysql://admin:secret@fe-example.starrocks.aliyuncs.com:9030/analytics?require_ssl=true&verify_ca=true&verify_identity=false&charset=utf8mb4",
);
});
test("mysql starrocks profile preserves tls params when ssl is enabled", () => {
const url = buildConnectionUrl(
starrocksConfig({
db_type: "mysql",
driver_profile: "starrocks",
ssl: true,
ca_cert_path: "/tmp/ca.pem",
}),
{ host: "fe-example.starrocks.aliyuncs.com", port: 9030 },
);
assert.equal(
url,
"mysql://admin:secret@fe-example.starrocks.aliyuncs.com:9030/analytics?require_ssl=true&verify_identity=false&charset=utf8mb4",
);
});

View File

@ -1,183 +0,0 @@
import assert from "node:assert/strict";
import { afterEach, test } from "vitest";
import { executeQuery, loadConnections, resetWebAuthForTests } from "../src/web-backend.js";
const originalFetch = globalThis.fetch;
const originalWebUrl = process.env.DBX_WEB_URL;
const originalWebPassword = process.env.DBX_WEB_PASSWORD;
afterEach(() => {
globalThis.fetch = originalFetch;
if (originalWebUrl === undefined) delete process.env.DBX_WEB_URL;
else process.env.DBX_WEB_URL = originalWebUrl;
if (originalWebPassword === undefined) delete process.env.DBX_WEB_PASSWORD;
else process.env.DBX_WEB_PASSWORD = originalWebPassword;
resetWebAuthForTests();
});
function jsonResponse(body: unknown, init?: ResponseInit): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) },
...init,
});
}
test("web backend rejects protected DBX Web access without DBX_WEB_PASSWORD", async () => {
process.env.DBX_WEB_URL = "http://127.0.0.1:4224";
delete process.env.DBX_WEB_PASSWORD;
const calls: string[] = [];
globalThis.fetch = (async (input) => {
const url = String(input);
calls.push(url);
if (url.endsWith("/api/auth/check")) {
return jsonResponse({ authenticated: false, required: true, setup_required: false });
}
throw new Error(`unexpected request: ${url}`);
}) as typeof fetch;
await assert.rejects(loadConnections(), /DBX_WEB_PASSWORD/);
assert.deepEqual(calls, ["http://127.0.0.1:4224/api/auth/check"]);
});
test("web backend rejects DBX Web access before password setup is complete", async () => {
process.env.DBX_WEB_URL = "http://127.0.0.1:4224";
delete process.env.DBX_WEB_PASSWORD;
const calls: string[] = [];
globalThis.fetch = (async (input) => {
const url = String(input);
calls.push(url);
if (url.endsWith("/api/auth/check")) {
return jsonResponse({ authenticated: false, required: false, setup_required: true });
}
throw new Error(`unexpected request: ${url}`);
}) as typeof fetch;
await assert.rejects(loadConnections(), /password setup is required/);
assert.deepEqual(calls, ["http://127.0.0.1:4224/api/auth/check"]);
});
test("web backend logs in with DBX_WEB_PASSWORD and sends the session cookie", async () => {
process.env.DBX_WEB_URL = "http://127.0.0.1:4224/";
process.env.DBX_WEB_PASSWORD = "secret";
const calls: Array<{ url: string; init?: RequestInit }> = [];
globalThis.fetch = (async (input, init) => {
const url = String(input);
calls.push({ url, init });
if (url.endsWith("/api/auth/check")) {
return jsonResponse({ authenticated: false, required: true, setup_required: false });
}
if (url.endsWith("/api/auth/login")) {
assert.equal(init?.method, "POST");
assert.equal(init?.body, JSON.stringify({ password: "secret" }));
return jsonResponse({ ok: true }, { headers: { "set-cookie": "dbx_session=session-1; Path=/; HttpOnly" } });
}
if (url.endsWith("/api/connection/list")) {
assert.equal((init?.headers as Record<string, string>).Cookie, "dbx_session=session-1");
return jsonResponse([
{
id: "1",
name: "local",
db_type: "postgres",
host: "127.0.0.1",
port: 5432,
username: "app",
password: "",
database: "demo",
ssh_enabled: false,
ssl: false,
},
]);
}
throw new Error(`unexpected request: ${url}`);
}) as typeof fetch;
const connections = await loadConnections();
assert.equal(connections[0]?.name, "local");
assert.deepEqual(
calls.map((call) => call.url),
[
"http://127.0.0.1:4224/api/auth/check",
"http://127.0.0.1:4224/api/auth/login",
"http://127.0.0.1:4224/api/connection/list",
],
);
});
test("web backend still allows DBX Web instances with password auth disabled", async () => {
process.env.DBX_WEB_URL = "http://127.0.0.1:4224";
delete process.env.DBX_WEB_PASSWORD;
globalThis.fetch = (async (input, init) => {
const url = String(input);
if (url.endsWith("/api/auth/check")) {
return jsonResponse({ authenticated: true, required: false, setup_required: false });
}
if (url.endsWith("/api/connection/list")) {
assert.equal((init?.headers as Record<string, string>).Cookie, undefined);
return jsonResponse([]);
}
throw new Error(`unexpected request: ${url}`);
}) as typeof fetch;
assert.deepEqual(await loadConnections(), []);
});
test("web backend routes MongoDB count commands through count-documents", async () => {
process.env.DBX_WEB_URL = "http://127.0.0.1:4224";
delete process.env.DBX_WEB_PASSWORD;
const calls: Array<{ url: string; init?: RequestInit }> = [];
globalThis.fetch = (async (input, init) => {
const url = String(input);
calls.push({ url, init });
if (url.endsWith("/api/auth/check")) {
return jsonResponse({ authenticated: true, required: false, setup_required: false });
}
if (url.endsWith("/api/connection/connect")) {
return jsonResponse({ ok: true });
}
if (url.endsWith("/api/mongo/count-documents")) {
assert.equal(init?.method, "POST");
assert.deepEqual(JSON.parse(String(init?.body)), {
connectionId: "mongo-web",
database: "app",
collection: "projects",
filter: '{"active":true}',
mode: "accurate",
});
return jsonResponse(42);
}
throw new Error(`unexpected request: ${url}`);
}) as typeof fetch;
const result = await executeQuery(
{
id: "mongo-web",
name: "mongo-web",
db_type: "mongodb",
host: "127.0.0.1",
port: 27017,
username: "",
password: "",
database: "app",
ssh_enabled: false,
ssl: false,
},
'db.projects.countDocuments({"active":true})',
);
assert.deepEqual(result, {
columns: ["count"],
rows: [{ count: 42 }],
row_count: 1,
});
assert.deepEqual(
calls.map((call) => call.url),
[
"http://127.0.0.1:4224/api/auth/check",
"http://127.0.0.1:4224/api/connection/connect",
"http://127.0.0.1:4224/api/mongo/count-documents",
],
);
});

View File

@ -1,14 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "nodenext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"outDir": "dist",
"rootDir": "src",
"declaration": true
},
"include": ["src"]
}

View File

@ -1,7 +0,0 @@
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/*.test.ts"],
},
});

File diff suppressed because it is too large Load Diff

View File

@ -10,13 +10,6 @@ const PACKAGE_TAG_PREFIX = "packages-v";
const AGENT_TAG_PREFIX = "agents-v";
const APP_TAG_PREFIX = "v";
const PACKAGE_RELEASE_PATHS = [
"packages/mongo-shell/src/",
"packages/mongo-shell/package.json",
"packages/mongo-shell/tsconfig.json",
"packages/node-core/src/",
"packages/node-core/README.md",
"packages/node-core/package.json",
"packages/node-core/tsconfig.json",
"packages/cli/src/",
"packages/cli/README.md",
"packages/cli/package.json",
@ -27,12 +20,10 @@ const PACKAGE_RELEASE_PATHS = [
"packages/cli-linux-x64-gnu/",
"packages/cli-win32-arm64/",
"packages/cli-win32-x64/",
"packages/mcp-server/src/",
"packages/mcp-server/bin/",
"packages/mcp-server/README.md",
"packages/mcp-server/package.json",
"packages/mcp-server/server.json",
"packages/mcp-server/tsconfig.json",
"packages/mcp-darwin-arm64/",
"packages/mcp-darwin-x64/",
"packages/mcp-linux-arm64-gnu/",
@ -277,8 +268,6 @@ function getLatestPackageVersion() {
if (tag) return tag.versionText;
const packageVersions = [
"packages/mongo-shell/package.json",
"packages/node-core/package.json",
"packages/cli/package.json",
"packages/mcp-server/package.json",
"packages/mcp-darwin-arm64/package.json",

View File

@ -5,8 +5,6 @@ import { spawnSync } from "node:child_process";
const packageDirectory = resolve(process.argv[2] ?? "/tmp/dbx-pack-check");
const expectedPackages = [
"@dbx-app/mongo-shell",
"@dbx-app/node-core",
"@dbx-app/cli",
"@dbx-app/mcp-server",
];

View File

@ -11,7 +11,7 @@ export default defineConfig({
},
},
test: {
include: ["packages/app-tests/*.test.ts", "packages/node-core/tests/*.test.ts", "apps/desktop/src/**/*.spec.ts", "docs/lib/*.test.ts"],
include: ["packages/app-tests/*.test.ts", "apps/desktop/src/**/*.spec.ts", "docs/lib/*.test.ts"],
globalSetup: "packages/test-globals.ts",
// Large store modules are dynamically imported in many specs. Limiting
// concurrency prevents those imports from starving timers and making