feat(mcp): remove legacy TypeScript runtime
This commit is contained in:
parent
a357ac7e93
commit
77c1203e6f
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 驱动工程 |
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -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:/);
|
||||
});
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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", {});
|
||||
}
|
||||
|
|
@ -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
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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}`;
|
||||
}
|
||||
|
|
@ -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) }));
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
|
|
@ -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" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -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,
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -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"),
|
||||
});
|
||||
});
|
||||
|
|
@ -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/,
|
||||
);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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"] });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
@ -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/);
|
||||
});
|
||||
|
|
@ -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/);
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
|
|
@ -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",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -1,7 +0,0 @@
|
|||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["tests/*.test.ts"],
|
||||
},
|
||||
});
|
||||
923
pnpm-lock.yaml
923
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue