From 23b44cc2b97d11691b0dfee6938395f4cd3a478f Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Fri, 26 Jun 2026 22:25:27 +0800 Subject: [PATCH] fix(mcp): add direct Redis command execution --- crates/dbx-core/src/ai_cli_agent.rs | 1 + packages/mcp-server/README.md | 54 +++--- packages/mcp-server/src/index.ts | 60 ++++++ packages/mcp-server/tests/server.test.ts | 104 +++++++++++ packages/node-core/package.json | 2 + packages/node-core/src/backend.ts | 5 +- packages/node-core/src/connections.ts | 5 +- packages/node-core/src/database.ts | 100 ++++++++++ packages/node-core/src/index.ts | 1 + packages/node-core/src/redis-command.ts | 172 ++++++++++++++++++ packages/node-core/src/web-backend.ts | 18 ++ .../node-core/tests/redis-command.test.ts | 36 ++++ packages/node-core/tests/redis-direct.test.ts | 142 +++++++++++++++ pnpm-lock.yaml | 39 ++++ src-tauri/src/commands/mcp_bridge.rs | 61 +++++++ 15 files changed, 774 insertions(+), 26 deletions(-) create mode 100644 packages/node-core/src/redis-command.ts create mode 100644 packages/node-core/tests/redis-command.test.ts create mode 100644 packages/node-core/tests/redis-direct.test.ts diff --git a/crates/dbx-core/src/ai_cli_agent.rs b/crates/dbx-core/src/ai_cli_agent.rs index f3010eb5d4..c578fe1001 100644 --- a/crates/dbx-core/src/ai_cli_agent.rs +++ b/crates/dbx-core/src/ai_cli_agent.rs @@ -52,6 +52,7 @@ pub fn dbx_mcp_enabled_tools(agent_mode: bool) -> Vec<&'static str> { let mut tools = vec!["dbx_list_connections", "dbx_list_tables", "dbx_describe_table", "dbx_get_schema_context"]; if agent_mode { tools.push("dbx_execute_query"); + tools.push("dbx_execute_redis_command"); } tools } diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 7dbeec00fd..da40f68d45 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -7,7 +7,7 @@ MCP server for [DBX](https://github.com/t8y2/dbx) — lets AI agents (Claude Cod ## Features - **Zero config** — Automatically reads your DBX connections (including passwords from system keyring) -- **8 tools** — List/add/remove connections, list tables, describe table, get schema context, execute SQL, open table in DBX UI +- **9 tools** — List/add/remove connections, list tables, describe table, get schema context, execute SQL, execute Redis commands, open table in DBX UI - **Connection pooling** — Reuses database connections across queries - **Direct execution** — PostgreSQL, MySQL, SQLite, and compatible databases (Doris, StarRocks, etc.) can run without opening DBX - **Writes enabled by default** — regular `INSERT` / `UPDATE` / `DELETE` statements work out of the box, while dangerous SQL stays blocked unless explicitly enabled @@ -79,16 +79,17 @@ See the [DBX CLI README](../cli/README.md) for command details. ## Tools -| Tool | Description | -| ------------------------ | ---------------------------------------------------- | -| `dbx_list_connections` | List all database connections configured in DBX | -| `dbx_add_connection` | Add a new database connection | -| `dbx_remove_connection` | Remove a database connection | -| `dbx_list_tables` | List tables and views for a connection | -| `dbx_describe_table` | Get column definitions for a table | -| `dbx_get_schema_context` | Get compact table and column context for writing SQL | -| `dbx_execute_query` | Execute a SQL query (max 100 rows) | -| `dbx_open_table` | Open a table in DBX desktop app UI | +| Tool | Description | +| --------------------------- | ---------------------------------------------------- | +| `dbx_list_connections` | List all database connections configured in DBX | +| `dbx_add_connection` | Add a new database connection | +| `dbx_remove_connection` | Remove a database connection | +| `dbx_list_tables` | List tables and views for a connection | +| `dbx_describe_table` | Get column definitions for a table | +| `dbx_get_schema_context` | Get compact table and column context for writing SQL | +| `dbx_execute_query` | Execute a SQL query (max 100 rows) | +| `dbx_execute_redis_command` | Execute a Redis command on a Redis connection | +| `dbx_open_table` | Open a table in DBX desktop app UI | ## SQL Safety @@ -106,6 +107,8 @@ Dangerous statements such as `DROP`, `TRUNCATE`, and `ALTER` remain blocked unle DBX_MCP_ALLOW_DANGEROUS_SQL=1 ``` +Redis connections use `dbx_execute_redis_command` instead of `dbx_execute_query`. Redis write commands honor `DBX_MCP_ALLOW_WRITES`; dangerous Redis commands such as `KEYS`, `FLUSHALL`, and `EVAL` require `DBX_MCP_ALLOW_DANGEROUS_SQL=1`. + ## How It Works ``` @@ -124,7 +127,7 @@ The MCP server reads your database connections from DBX's SQLite database: The `dbx_open_table` tool communicates with the running DBX app to open tables directly in the UI. This requires DBX to be running. If DBX is not running, the tool will return an error message. -PostgreSQL, MySQL, SQLite, Doris, StarRocks, and Redshift queries run directly from the MCP server. Other database types still use the DBX desktop bridge for query, table, and column operations unless `DBX_WEB_URL` is configured. +PostgreSQL, MySQL, SQLite, Doris, StarRocks, and Redshift queries run directly from the MCP server. Redis standalone command execution also runs directly. Other database types, plus Redis Sentinel/Cluster or SSH-backed Redis connections, still use the DBX desktop bridge unless `DBX_WEB_URL` is configured. ## Requirements @@ -144,7 +147,7 @@ Apache-2.0 ### 特性 - **零配置** — 自动读取 DBX 的连接配置 -- **8 个工具** — 列出/添加/删除连接、列出表、查看表结构、获取 Schema 上下文、执行 SQL、在 DBX 中打开表 +- **9 个工具** — 列出/添加/删除连接、列出表、查看表结构、获取 Schema 上下文、执行 SQL、执行 Redis 命令、在 DBX 中打开表 - **连接池** — 跨查询复用数据库连接 - **直接执行** — PostgreSQL、MySQL、SQLite 及兼容数据库(Doris、StarRocks 等)无需打开 DBX 即可查询 - **默认允许常规写入** — `INSERT` / `UPDATE` / `DELETE` 可直接执行,危险语句仍需显式开启 @@ -202,16 +205,17 @@ dbx query local "select 1" --json ### 工具列表 -| 工具 | 说明 | -| ------------------------ | ------------------------------------- | -| `dbx_list_connections` | 列出 DBX 中所有已配置的数据库连接 | -| `dbx_add_connection` | 添加新的数据库连接 | -| `dbx_remove_connection` | 删除数据库连接 | -| `dbx_list_tables` | 列出指定连接的表和视图 | -| `dbx_describe_table` | 获取表的列定义 | -| `dbx_get_schema_context` | 获取适合 AI 写 SQL 的紧凑表结构上下文 | -| `dbx_execute_query` | 执行 SQL 查询(最多返回 100 行) | -| `dbx_open_table` | 在 DBX 桌面端打开指定表 | +| 工具 | 说明 | +| --------------------------- | ------------------------------------- | +| `dbx_list_connections` | 列出 DBX 中所有已配置的数据库连接 | +| `dbx_add_connection` | 添加新的数据库连接 | +| `dbx_remove_connection` | 删除数据库连接 | +| `dbx_list_tables` | 列出指定连接的表和视图 | +| `dbx_describe_table` | 获取表的列定义 | +| `dbx_get_schema_context` | 获取适合 AI 写 SQL 的紧凑表结构上下文 | +| `dbx_execute_query` | 执行 SQL 查询(最多返回 100 行) | +| `dbx_execute_redis_command` | 在 Redis 连接上执行 Redis 命令 | +| `dbx_open_table` | 在 DBX 桌面端打开指定表 | ### SQL 安全 @@ -229,6 +233,8 @@ DBX_MCP_ALLOW_WRITES=0 DBX_MCP_ALLOW_DANGEROUS_SQL=1 ``` +Redis 连接使用 `dbx_execute_redis_command`,不通过 `dbx_execute_query` 执行。Redis 写命令遵循 `DBX_MCP_ALLOW_WRITES`;`KEYS`、`FLUSHALL`、`EVAL` 等危险 Redis 命令需要设置 `DBX_MCP_ALLOW_DANGEROUS_SQL=1`。 + ### 工作原理 MCP Server 从 DBX 的 SQLite 数据库读取连接信息: @@ -241,7 +247,7 @@ MCP Server 从 DBX 的 SQLite 数据库读取连接信息: `dbx_open_table` 工具通过本地 HTTP 接口与运行中的 DBX 应用通信,直接在 UI 中打开表。需要 DBX 正在运行。 -PostgreSQL、MySQL、SQLite、Doris、StarRocks、Redshift 查询可由 MCP Server 直接执行。其他数据库类型的查询、表列表、字段读取仍会走 DBX 桌面端 bridge,除非配置了 `DBX_WEB_URL` 使用 Web 后端。 +PostgreSQL、MySQL、SQLite、Doris、StarRocks、Redshift 查询可由 MCP Server 直接执行。Redis standalone 命令执行也会直接连接。其他数据库类型,以及 Redis Sentinel/Cluster 或 SSH Redis 连接,仍会走 DBX 桌面端 bridge,除非配置了 `DBX_WEB_URL` 使用 Web 后端。 ### 系统要求 diff --git a/packages/mcp-server/src/index.ts b/packages/mcp-server/src/index.ts index 556ab16379..588647b145 100644 --- a/packages/mcp-server/src/index.ts +++ b/packages/mcp-server/src/index.ts @@ -6,6 +6,7 @@ import { z } from "zod"; import { buildSchemaContext, createBackend, + evaluateRedisCommandSafety, evaluateMongoAggregateSafety, evaluateSqlSafety, formatCell, @@ -20,6 +21,7 @@ import { type Backend, type ConnectionConfig, type QueryResult, + type RedisCommandResult, } from "@dbx-app/node-core"; const require = createRequire(import.meta.url); @@ -45,6 +47,26 @@ function formatQueryToolResult(result: QueryResult, title?: string) { 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, 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, db2, informix, influxdb, iris, neo4j, cassandra, bigquery, kylin, sundb, tdengine, iotdb, xugu, zookeeper, jdbc, access, mq"; const FILE_CAPABLE_CONNECTION_TYPES = new Set(["sqlite", "duckdb", "access", "h2"]); @@ -162,6 +184,12 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool const { config, error } = await resolveConnection(backend, scope, 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 safety = evaluateSqlSafety(sql, { ...sqlSafetyFromEnv(), allowMultipleStatements: true }); if (!safety.allowed) return toolError("SQL_BLOCKED", safety.reason ?? "SQL blocked."); @@ -183,6 +211,38 @@ export function createDbxMcpServer(backend: Backend, options: { isWebMode?: bool }, ); + server.tool( + "dbx_execute_redis_command", + "Execute a Redis command on a Redis connection", + { + 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_name, db, command }) => { + const { config, error } = await resolveConnection(backend, scope, 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."); + try { + const result = await backend.executeRedisCommand(scopedConfig, defaultRedisDb(scopedConfig, scope, db), command, { + skipSafetyCheck: safety.skipSafetyCheck, + }); + return formatRedisCommandToolResult(result); + } 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", diff --git a/packages/mcp-server/tests/server.test.ts b/packages/mcp-server/tests/server.test.ts index 197b983424..9127d8c243 100644 --- a/packages/mcp-server/tests/server.test.ts +++ b/packages/mcp-server/tests/server.test.ts @@ -192,6 +192,110 @@ test("scoped MCP with writes disabled blocks write SQL", async () => { 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, + findConnection: 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, + findConnection: 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("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, + findConnection: async () => redisConnection, + executeRedisCommand: async () => { + executed = true; + return { command: "SET", safety: "confirm", 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, + findConnection: 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" }; diff --git a/packages/node-core/package.json b/packages/node-core/package.json index 1a5f9d10ac..92d7bc719f 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -17,6 +17,7 @@ "./entrypoint": "./dist/entrypoint.js", "./format": "./dist/format.js", "./paths": "./dist/paths.js", + "./redis-command": "./dist/redis-command.js", "./schema-context": "./dist/schema-context.js", "./sql-safety": "./dist/sql-safety.js" }, @@ -27,6 +28,7 @@ }, "dependencies": { "better-sqlite3": "^12.9.0", + "ioredis": "^5.11.1", "keytar": "^7.9.0", "mysql2": "^3.14.1", "pg": "^8.16.0" diff --git a/packages/node-core/src/backend.ts b/packages/node-core/src/backend.ts index 22f172d2b6..ce571acb6d 100644 --- a/packages/node-core/src/backend.ts +++ b/packages/node-core/src/backend.ts @@ -1,7 +1,8 @@ import { addConnection as desktopAddConnection, findConnection as desktopFindConnection, loadConnections as desktopLoadConnections, removeConnection as desktopRemoveConnection } from "./connections.js"; -import { closeDatabaseResources as desktopCloseDatabaseResources, describeTable as desktopDescribeTable, executeQuery as desktopExecuteQuery, listTables as desktopListTables } from "./database.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; @@ -11,6 +12,7 @@ export interface Backend { listTables(config: ConnectionConfig, schema?: string): Promise; describeTable(config: ConnectionConfig, table: string, schema?: string): Promise; executeQuery(config: ConnectionConfig, sql: string, options?: QueryOptions): Promise; + executeRedisCommand?(config: ConnectionConfig, db: number, command: string, options?: RedisCommandOptions): Promise; close?(): Promise; } @@ -27,6 +29,7 @@ export async function createBackend(env: NodeJS.ProcessEnv = process.env): Promi listTables: desktopListTables, describeTable: desktopDescribeTable, executeQuery: desktopExecuteQuery, + executeRedisCommand: desktopExecuteRedisCommand, close: desktopCloseDatabaseResources, }; } diff --git a/packages/node-core/src/connections.ts b/packages/node-core/src/connections.ts index 0a15b05bc1..908eb9774b 100644 --- a/packages/node-core/src/connections.ts +++ b/packages/node-core/src/connections.ts @@ -19,13 +19,16 @@ export interface ConnectionConfig { 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"; + 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; } diff --git a/packages/node-core/src/database.ts b/packages/node-core/src/database.ts index c47950571e..cdb55379f6 100644 --- a/packages/node-core/src/database.ts +++ b/packages/node-core/src/database.ts @@ -7,6 +7,7 @@ import Database from "better-sqlite3"; import { sqlSafetyFromEnv } from "./sql-safety.js"; import { isDirectQueryType } from "./diagnostics.js"; import { bridgePortFilePath } from "./paths.js"; +import { parseRedisCommandArgv, classifyRedisCommand, type RedisCommandOptions, type RedisCommandResult, type RedisCommandSafety } from "./redis-command.js"; export interface TableInfo { name: string; @@ -169,6 +170,11 @@ function firstProxyLayer(config: ConnectionConfig): ProxyLayer | undefined { return config.transport_layers?.find((layer): layer is ProxyLayer => layer.type === "proxy" && layer.enabled !== false && !!layer.host); } +function hasDirectRedisSupport(config: ConnectionConfig): boolean { + const mode = config.redis_connection_mode || "standalone"; + return config.db_type === "redis" && mode === "standalone" && !hasActiveSshLayer(config); +} + async function connectionEndpoint(config: ConnectionConfig): Promise<{ host: string; port: number }> { const proxy = firstProxyLayer(config); if (!proxy) return { host: config.host, port: config.port }; @@ -293,6 +299,10 @@ function decodeUrlParamPart(value: string): string { } } +function urlParams(config: ConnectionConfig): URLSearchParams { + return new URLSearchParams((config.url_params || "").trim().replace(/^\?/, "")); +} + function connectViaProxy(config: ConnectionConfig, proxy: ProxyLayer): Promise { return new Promise((resolve, reject) => { const socket = netConnect(proxy.port || 1080, proxy.host || "127.0.0.1"); @@ -659,6 +669,96 @@ export async function executeQuery(config: ConnectionConfig, sql: string, option return convertBridgeQueryResult(result, options); } +export async function executeRedisCommand(config: ConnectionConfig, db: number, command: string, options?: RedisCommandOptions): Promise { + if (config.db_type !== "redis") { + throw new Error("Connection is not Redis."); + } + if (hasDirectRedisSupport(config)) { + return executeRedisCommandDirect(config, db, command, options); + } + return withTimeout( + bridgeDataRequest("/data/redis/execute-command", { + connection_name: config.name, + db, + command, + skip_safety_check: options?.skipSafetyCheck ?? false, + }), + resolveTimeoutMs(options), + ); +} + +async function executeRedisCommandDirect(config: ConnectionConfig, db: number, commandText: string, options?: RedisCommandOptions): Promise { + const argv = parseRedisCommandArgv(commandText); + const command = argv[0].toUpperCase(); + const safety = classifyRedisCommand(command) as RedisCommandSafety; + if (!options?.skipSafetyCheck && safety === "blocked") { + throw new Error(`Redis command is blocked for safety: ${command}`); + } + + const { Redis } = await import("ioredis"); + const endpoint = await connectionEndpoint(config); + const tls = await redisTlsOptions(config); + const client = new Redis({ + host: endpoint.host, + port: endpoint.port, + username: config.username || undefined, + password: config.password || undefined, + db, + tls, + lazyConnect: true, + enableReadyCheck: false, + maxRetriesPerRequest: 0, + enableOfflineQueue: false, + connectTimeout: Math.min(resolveTimeoutMs(options), 10_000), + commandTimeout: resolveTimeoutMs(options), + }); + + try { + await client.connect(); + const value = await client.call(command, ...argv.slice(1)); + return { command, safety, value: redisValueToJson(value) }; + } finally { + client.disconnect(); + } +} + +async function redisTlsOptions(config: ConnectionConfig): Promise { + if (!config.ssl) return undefined; + const params = urlParams(config); + const tls: import("node:tls").ConnectionOptions = { + servername: config.host, + }; + if ((params.get("insecure") || "").toLowerCase() === "true") { + tls.rejectUnauthorized = false; + } + if (config.ca_cert_path) tls.ca = await readFile(config.ca_cert_path); + if (config.client_cert_path) tls.cert = await readFile(config.client_cert_path); + if (config.client_key_path) tls.key = await readFile(config.client_key_path); + return tls; +} + +function redisValueToJson(value: unknown): unknown { + if (Buffer.isBuffer(value)) return redisTextToJson(value.toString("utf8")); + if (Array.isArray(value)) return value.map(redisValueToJson); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, redisValueToJson(item)])); + } + if (typeof value === "string") return redisTextToJson(value); + return value; +} + +function redisTextToJson(value: string): unknown { + const trimmed = value.trim(); + if (trimmed.startsWith("{") || trimmed.startsWith("[")) { + try { + return JSON.parse(trimmed); + } catch { + return value; + } + } + return value; +} + export async function listTables(config: ConnectionConfig, schema?: string): Promise { if (config.db_type === "mongodb") { const collections = await bridgeDataRequest("/data/mongo/list-collections", { diff --git a/packages/node-core/src/index.ts b/packages/node-core/src/index.ts index be93ef12e7..f00c0f97b6 100644 --- a/packages/node-core/src/index.ts +++ b/packages/node-core/src/index.ts @@ -6,5 +6,6 @@ export * from "./diagnostics.js"; export * from "./entrypoint.js"; export * from "./format.js"; export * from "./paths.js"; +export * from "./redis-command.js"; export * from "./schema-context.js"; export * from "./sql-safety.js"; diff --git a/packages/node-core/src/redis-command.ts b/packages/node-core/src/redis-command.ts new file mode 100644 index 0000000000..443bd3cc54 --- /dev/null +++ b/packages/node-core/src/redis-command.ts @@ -0,0 +1,172 @@ +import type { SqlSafetyOptions } from "./sql-safety.js"; + +export type RedisCommandSafety = "allowed" | "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", + "PERSIST", + "RENAME", + "RENAMENX", + "SET", + "SETEX", + "PSETEX", + "SETNX", + "MSET", + "MSETNX", + "HSET", + "HDEL", + "LPUSH", + "RPUSH", + "LPOP", + "RPOP", + "LSET", + "LREM", + "SADD", + "SREM", + "ZADD", + "ZREM", + "XADD", + "XDEL", + "FLUSHDB", +]); + +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"; + 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; +} diff --git a/packages/node-core/src/web-backend.ts b/packages/node-core/src/web-backend.ts index 68602eeb13..6420d43447 100644 --- a/packages/node-core/src/web-backend.ts +++ b/packages/node-core/src/web-backend.ts @@ -1,6 +1,7 @@ import type { ConnectionConfig } from "./connections.js"; import type { TableInfo, ColumnInfo, QueryOptions, QueryResult } from "./database.js"; import { collectionListToTableInfos, evaluateMongoAggregateSafety, evaluateMongoWriteSafety, inferMongoColumns, mongoDocumentsToQueryResult, parseMongoAggregateCommand, parseMongoCountDocumentsCommand, parseMongoFindCommand, parseMongoGetIndexesCommand, parseMongoWriteCommand, type CollectionInfo, type MongoWriteCommand } from "./database.js"; +import type { RedisCommandOptions, RedisCommandResult } from "./redis-command.js"; import { sqlSafetyFromEnv } from "./sql-safety.js"; const baseUrl = process.env.DBX_WEB_URL!.replace(/\/+$/, ""); @@ -222,6 +223,23 @@ export async function executeQuery(config: ConnectionConfig, sql: string, option return { columns: data.columns, rows: limitedRows, row_count: limitedRows.length }; } +export async function executeRedisCommand(config: ConnectionConfig, db: number, command: string, options?: RedisCommandOptions): Promise { + 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 { if (command.kind === "insert") { const res = await apiFetch("/api/mongo/insert-documents", { diff --git a/packages/node-core/tests/redis-command.test.ts b/packages/node-core/tests/redis-command.test.ts new file mode 100644 index 0000000000..28c497eacb --- /dev/null +++ b/packages/node-core/tests/redis-command.test.ts @@ -0,0 +1,36 @@ +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"), "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); +}); diff --git a/packages/node-core/tests/redis-direct.test.ts b/packages/node-core/tests/redis-direct.test.ts new file mode 100644 index 0000000000..a163502939 --- /dev/null +++ b/packages/node-core/tests/redis-direct.test.ts @@ -0,0 +1,142 @@ +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(handler: (request: RedisRequest) => string, fn: (port: number, seen: RedisRequest[]) => Promise): Promise { + 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((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((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: "confirm", 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"] }); + }, + ); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8b528b2db7..559e5ab4aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -260,6 +260,9 @@ importers: better-sqlite3: specifier: ^12.9.0 version: 12.10.0 + ioredis: + specifier: ^5.11.1 + version: 5.11.1 keytar: specifier: ^7.9.0 version: 7.9.0 @@ -4014,6 +4017,23 @@ packages: zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + '@ioredis/commands@1.10.0': + resolution: {integrity: sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q==} + cluster-key-slot@1.1.1: + resolution: {integrity: sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==} + engines: {node: '>=0.10.0'} + ioredis@5.11.1: + resolution: {integrity: sha512-ehuGcf94bQXhfagULNXrJdfnWO38v070jxSx/qE87Kjzmu2fU7ro5EFAb+OPituLqgfyuQaym5DlrNydW2sJ9A==} + engines: {node: '>=12.22.0'} + redis-errors@1.2.0: + resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} + engines: {node: '>=4'} + redis-parser@3.0.0: + resolution: {integrity: sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==} + engines: {node: '>=4'} + standard-as-callback@2.1.0: + resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + snapshots: '@babel/code-frame@7.29.0': @@ -7675,3 +7695,22 @@ snapshots: tslib: 2.3.0 zwitch@2.0.4: {} + + '@ioredis/commands@1.10.0': {} + cluster-key-slot@1.1.1: {} + ioredis@5.11.1: + dependencies: + '@ioredis/commands': 1.10.0 + cluster-key-slot: 1.1.1 + debug: 4.4.3 + denque: 2.1.0 + redis-errors: 1.2.0 + redis-parser: 3.0.0 + standard-as-callback: 2.1.0 + transitivePeerDependencies: + - supports-color + redis-errors@1.2.0: {} + redis-parser@3.0.0: + dependencies: + redis-errors: 1.2.0 + standard-as-callback@2.1.0: {} diff --git a/src-tauri/src/commands/mcp_bridge.rs b/src-tauri/src/commands/mcp_bridge.rs index 3526f84840..8aae7a0832 100644 --- a/src-tauri/src/commands/mcp_bridge.rs +++ b/src-tauri/src/commands/mcp_bridge.rs @@ -90,6 +90,14 @@ struct MongoDeleteDocumentsRequest { many: bool, } +#[derive(Deserialize)] +struct RedisCommandRequest { + connection_name: String, + db: u32, + command: String, + skip_safety_check: Option, +} + #[derive(Clone, Serialize)] pub struct McpOpenTableEvent { pub connection_id: String, @@ -157,6 +165,8 @@ pub fn start(app_handle: AppHandle, state: Arc) { handle_mongo_update_documents_data(&st, body, &mut stream).await; } else if first_line.starts_with("POST /data/mongo/delete-documents") { handle_mongo_delete_documents_data(&st, body, &mut stream).await; + } else if first_line.starts_with("POST /data/redis/execute-command") { + handle_redis_execute_command_data(&st, body, &mut stream).await; } else if first_line.starts_with("POST /data/execute-query") { handle_execute_query_data(&st, body, &mut stream).await; } else if first_line.starts_with("POST /execute-query") { @@ -527,6 +537,57 @@ async fn handle_mongo_delete_documents_data(state: &Arc, body: &str, s } } +async fn handle_redis_execute_command_data(state: &Arc, body: &str, stream: &mut tokio::net::TcpStream) { + let req: RedisCommandRequest = match serde_json::from_str(body) { + Ok(r) => r, + Err(_) => { + respond_error(stream, "400 Bad Request", "Invalid JSON").await; + return; + } + }; + let config = match resolve_connection(state, &req.connection_name).await { + Ok(c) => c, + Err(e) => { + respond_error(stream, "404 Not Found", &e).await; + return; + } + }; + let database = req.db.to_string(); + if let Err(e) = check_visible_database(&config, &database) { + respond_error(stream, "403 Forbidden", &e).await; + return; + } + if let Some(name) = dbx_core::query::connection_readonly_name(state, &config.id).await { + let cmd_name = req.command.split_whitespace().next().unwrap_or(""); + if dbx_core::db::redis_driver::classify_command(cmd_name) + != dbx_core::db::redis_driver::RedisCommandSafety::Allowed + { + respond_error( + stream, + "403 Forbidden", + &format!( + "Read-only mode: connection '{}' has read-only protection enabled. Command '{}' blocked.", + name, cmd_name + ), + ) + .await; + return; + } + } + match dbx_core::redis_ops::redis_execute_command_core( + state, + &config.id, + req.db, + &req.command, + req.skip_safety_check.unwrap_or(false), + ) + .await + { + Ok(result) => respond_json(stream, &result).await, + Err(e) => respond_error(stream, "500 Internal Server Error", &e).await, + } +} + async fn handle_execute_query_data(state: &Arc, body: &str, stream: &mut tokio::net::TcpStream) { let req: ExecuteQueryRequest = match serde_json::from_str(body) { Ok(r) => r,