fix(mcp): require auth for web backend access

This commit is contained in:
二丫讲梵 2026-07-08 19:23:34 +08:00 committed by GitHub
parent 50d4d22f05
commit fb919efe0a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 306 additions and 24 deletions

View File

@ -158,6 +158,23 @@ Add to your `.mcp.json`:
Windows portable builds need `DBX_DATA_DIR` in the MCP config, pointing to the `data` directory next to `DBX.exe` (the folder that contains `dbx.db`).
For DBX Web or Docker deployments, point the MCP server at the Web backend API. If the Web login page requires a password, set `DBX_WEB_PASSWORD` to the same password used there:
```json
{
"mcpServers": {
"dbx": {
"command": "npx",
"args": ["-y", "@dbx-app/mcp-server"],
"env": {
"DBX_WEB_URL": "http://localhost:4224",
"DBX_WEB_PASSWORD": "your-web-login-password"
}
}
}
}
```
Works with Claude Code, Cursor, Windsurf, and any MCP-compatible agent. Supports listing connections, browsing tables, executing SQL, and opening tables directly in DBX's UI.
DBX also provides a dedicated CLI package for terminal, script, and Codex workflows:

View File

@ -159,6 +159,23 @@ npx @dbx-app/mcp-server
Windows 便携版需要在 MCP 配置中设置 `DBX_DATA_DIR`,指向 `DBX.exe` 同级的 `data` 目录(即包含 `dbx.db` 的文件夹)。
如果连接的是 DBX Web 或 Docker 部署,请让 MCP Server 指向 Web 后端 API。如果 Web 登录页需要密码,`DBX_WEB_PASSWORD` 填写同一个 Web 登录密码:
```json
{
"mcpServers": {
"dbx": {
"command": "npx",
"args": ["-y", "@dbx-app/mcp-server"],
"env": {
"DBX_WEB_URL": "http://localhost:4224",
"DBX_WEB_PASSWORD": "你的 Web 登录密码"
}
}
}
}
```
支持 Claude Code、Cursor、Windsurf 等 MCP 兼容的 AI 助手。可列出连接、浏览表、执行 SQL还能直接在 DBX 界面中打开表。
DBX 也提供独立 CLI 包,适合终端、脚本和 Codex 工作流:

View File

@ -48,6 +48,19 @@ fn api_path_suffix<'a>(path: &'a str, public_base_path: &str) -> Option<&'a str>
path.strip_prefix(base)?.strip_prefix("/api/")
}
fn middleware_api_path_suffix<'a>(path: &'a str, public_base_path: &str) -> Option<&'a str> {
if let Some(suffix) = api_path_suffix(path, public_base_path) {
return Some(suffix);
}
let base = public_base_path.trim_end_matches('/');
if !base.is_empty() && base != "/" && path.strip_prefix(base).is_some() {
return None;
}
path.strip_prefix('/').filter(|suffix| !suffix.is_empty())
}
pub async fn login(State(state): State<Arc<WebState>>, Json(body): Json<LoginRequest>) -> Result<Response, StatusCode> {
let hash_guard = state.password_hash.read().await;
let hash_str = match hash_guard.as_deref() {
@ -206,22 +219,25 @@ pub async fn auth_middleware(
req: Request<axum::body::Body>,
next: Next,
) -> Response {
// No password set — allow everything
if state.password_hash.read().await.is_none() {
return next.run(req).await;
}
// Auth endpoints are always accessible
let api_suffix = api_path_suffix(req.uri().path(), &state.public_base_path);
// Auth endpoints are always accessible.
let api_suffix = middleware_api_path_suffix(req.uri().path(), &state.public_base_path);
if api_suffix.is_some_and(|suffix| suffix.starts_with("auth/")) {
return next.run(req).await;
}
// Non-API requests (static files) are always accessible
// Non-API requests (static files) are always accessible.
if api_suffix.is_none() {
return next.run(req).await;
}
if state.password_disabled {
return next.run(req).await;
}
if state.password_hash.read().await.is_none() {
return StatusCode::UNAUTHORIZED.into_response();
}
// Check session token
if let Some(token) = extract_session_token(&req) {
if state.sessions.read().await.contains(&token) {
@ -234,7 +250,7 @@ pub async fn auth_middleware(
#[cfg(test)]
mod tests {
use super::api_path_suffix;
use super::{api_path_suffix, middleware_api_path_suffix};
#[test]
fn api_path_suffix_handles_root_api_paths() {
@ -249,4 +265,13 @@ mod tests {
assert_eq!(api_path_suffix("/tools/dbx/api/query/execute", "/tools/dbx"), Some("query/execute"));
assert_eq!(api_path_suffix("/dbx/login", "/dbx"), None);
}
#[test]
fn middleware_api_path_suffix_handles_nested_router_paths() {
assert_eq!(middleware_api_path_suffix("/auth/check", "/"), Some("auth/check"));
assert_eq!(middleware_api_path_suffix("/connection/list", "/"), Some("connection/list"));
assert_eq!(middleware_api_path_suffix("/api/connection/list", "/"), Some("connection/list"));
assert_eq!(middleware_api_path_suffix("/dbx/api/connection/list", "/dbx"), Some("connection/list"));
assert_eq!(middleware_api_path_suffix("/dbx/login", "/dbx"), None);
}
}

View File

@ -256,6 +256,8 @@ MCP 查询执行默认非常保守:
| 桌面本地模式 | 默认 | 读取 DBX 桌面端连接存储,并可调用桌面专属 UI bridge 工具 |
| Web 模式 | 设置 `DBX_WEB_URL` | 不读取本机桌面存储,而是把请求发给 DBX Web 后端 |
对于启用了密码保护的 DBX Web 或 Docker 部署,还需要在 MCP Server 环境变量中设置 `DBX_WEB_PASSWORD`。这里填写的就是 DBX Web 登录页使用的密码,也包括首次打开 Web 页面时通过 setup 设置的密码。为了让 MCP 可用,不需要在启动 DBX Web 时额外设置 `DBX_PASSWORD``DBX_PASSWORD` 只是服务端环境变量覆盖。当 Web 后端要求认证但没有提供密码时MCP 调用会在返回任何连接数据前失败。桌面本地模式不使用 `DBX_WEB_PASSWORD`。
## 常见问题
<Accordions>
@ -263,7 +265,7 @@ MCP 查询执行默认非常保守:
<Accordion title="dbx_open_table 报 'DBX is not running'">需要先启动 DBX 桌面应用。UI 联动功能依赖 DBX 运行时的本地 HTTP 服务(端口 4224。</Accordion>
<Accordion title="连接名称找不到">连接名称匹配不区分大小写,但需要和 DBX 中配置的名称一致。用 `dbx_list_connections` 查看所有可用名称。</Accordion>
<Accordion title="查询超时">MCP Server 的查询超时为 30 秒。如果查询较慢,考虑添加索引或简化查询。也可以让 AI 助手先用 `dbx_describe_table` 了解表结构,再生成优化后的查询。</Accordion>
<Accordion title="Docker 环境下如何使用 MCP">Docker 部署的 DBX 同样支持 MCP。MCP Server 会读取 Docker 容器内的连接配置。确保 MCP Server 能访问到 DBX 的配置目录。</Accordion>
<Accordion title="Docker 环境下如何使用 MCP">把 `DBX_WEB_URL` 设置为 DBX Web 地址。如果 Web 页面需要密码,也要把 `DBX_WEB_PASSWORD` 设置为同一个 Web 登录密码;否则 MCP 会拒绝访问受保护的 Web API。</Accordion>
</Accordions>
## 系统要求

View File

@ -256,6 +256,8 @@ Set `DBX_MCP_ALLOW_WRITES=1` to allow write statements. Set `DBX_MCP_ALLOW_DANGE
| Desktop local mode | Default | Reads DBX desktop connection storage and can call desktop-only UI bridge tools |
| Web mode | Set `DBX_WEB_URL` | Sends requests to the DBX web backend instead of reading local desktop storage |
For DBX Web or Docker deployments that have password protection enabled, also set `DBX_WEB_PASSWORD` in the MCP server environment. This is the same password used on the DBX Web login page, including the password created by the first-run setup screen. You do not need to set `DBX_PASSWORD` on the DBX Web server just for MCP; `DBX_PASSWORD` is only a server-side environment override. MCP calls fail before returning connection data when the Web backend requires authentication and no password is provided. Desktop local mode does not use `DBX_WEB_PASSWORD`.
## FAQ
<Accordions>
@ -263,7 +265,7 @@ Set `DBX_MCP_ALLOW_WRITES=1` to allow write statements. Set `DBX_MCP_ALLOW_DANGE
<Accordion title="dbx_open_table says 'DBX is not running'">Start the DBX desktop app first. UI integration requires DBX's local HTTP service (port 4224).</Accordion>
<Accordion title="Connection name not found">Connection name matching is case-insensitive but must match the name configured in DBX. Use `dbx_list_connections` to see all available names.</Accordion>
<Accordion title="Query timeout">MCP Server has a 30-second query timeout. Consider adding indexes or simplifying your query. You can also ask the AI to use `dbx_describe_table` first to understand the schema, then generate an optimized query.</Accordion>
<Accordion title="How to use MCP with Docker?">DBX deployed via Docker also supports MCP. The MCP Server reads connection configs from inside the Docker container. Make sure the MCP Server can access DBX's config directory.</Accordion>
<Accordion title="How to use MCP with Docker?">Set `DBX_WEB_URL` to your DBX Web URL. If the Web UI requires a password, set `DBX_WEB_PASSWORD` to the same password used on the Web login page; otherwise MCP will refuse protected Web API access.</Accordion>
</Accordions>
## Requirements

View File

@ -8,7 +8,7 @@
"dev": "vite --config apps/desktop/vite.config.ts",
"dev:tauri": "tauri dev",
"dev:web": "vite --config apps/desktop/vite.config.ts --port 5173 --mode web",
"dev:backend": "RUST_LOG=${RUST_LOG:-info} DBX_PASSWORD=${DBX_PASSWORD:-test} cargo watch -x 'run -p dbx-web'",
"dev:backend": "RUST_LOG=${RUST_LOG:-info} sh -c 'if cargo watch --version >/dev/null 2>&1; then cargo watch -x \"run -p dbx-web\"; else echo \"cargo-watch is not installed; running dbx-web without hot reload. Install with: cargo install cargo-watch\"; cargo run -p dbx-web; fi'",
"build:packages": "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/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",

View File

@ -140,6 +140,26 @@ The MCP server reads your database connections from DBX's SQLite database:
Windows portable builds store data next to `DBX.exe`, usually in `data\dbx.db`. Set `DBX_DATA_DIR` to that `data` folder instead of copying `dbx.db` into the default directory.
## DBX Web / Docker Mode
When connecting MCP to a deployed DBX Web instance, set `DBX_WEB_URL` instead of reading local desktop storage:
```json
{
"mcpServers": {
"dbx": {
"command": "dbx-mcp-server",
"env": {
"DBX_WEB_URL": "https://dbx.example.com",
"DBX_WEB_PASSWORD": "your-web-password"
}
}
}
}
```
If the Web instance has password protection enabled, `DBX_WEB_PASSWORD` is required. Use the same password you enter on the DBX Web login page, including the password created by the first-run setup screen. You do not need to set `DBX_PASSWORD` on the DBX Web server just for MCP; `DBX_PASSWORD` is only a server-side environment override. Without `DBX_WEB_PASSWORD`, MCP calls fail before any connection data is returned. Desktop local mode does not use `DBX_WEB_PASSWORD`.
## DBX UI Integration
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.
@ -277,6 +297,26 @@ MCP Server 从 DBX 的 SQLite 数据库读取连接信息:
Windows 便携版的数据通常在 `DBX.exe` 同级的 `data\dbx.db`。请把 `DBX_DATA_DIR` 设置为这个 `data` 文件夹,不要手工复制 `dbx.db` 到默认目录。
### DBX Web / Docker 模式
如果 MCP 连接的是已部署的 DBX Web 实例,请设置 `DBX_WEB_URL`,不要读取本机桌面端存储:
```json
{
"mcpServers": {
"dbx": {
"command": "dbx-mcp-server",
"env": {
"DBX_WEB_URL": "https://dbx.example.com",
"DBX_WEB_PASSWORD": "你的 Web 访问密码"
}
}
}
}
```
当 Web 实例启用了密码保护时,必须提供 `DBX_WEB_PASSWORD`。这里填写的就是 DBX Web 登录页使用的密码,也包括首次打开 Web 页面时通过 setup 设置的密码。为了让 MCP 可用,不需要在启动 DBX Web 时额外设置 `DBX_PASSWORD``DBX_PASSWORD` 只是服务端环境变量覆盖。未提供 `DBX_WEB_PASSWORD`MCP 调用会在返回任何连接数据前失败。桌面本地模式不使用 `DBX_WEB_PASSWORD`
### DBX UI 联动
`dbx_open_table` 工具通过本地 HTTP 接口与运行中的 DBX 应用通信,直接在 UI 中打开表。需要 DBX 正在运行。

View File

@ -4,16 +4,58 @@ import { collectionListToTableInfos, evaluateMongoAggregateSafety, evaluateMongo
import type { RedisCommandOptions, RedisCommandResult } from "./redis-command.js";
import { sqlSafetyFromEnv } from "./sql-safety.js";
const baseUrl = process.env.DBX_WEB_URL!.replace(/\/+$/, "");
const password = process.env.DBX_WEB_PASSWORD || "";
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 (!password) return; // no password set, assume no auth required
if (authChecked) return;
const res = await fetch(`${baseUrl}/api/auth/login`, {
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 }),
@ -24,13 +66,11 @@ async function ensureAuth(): Promise<void> {
throw new Error(`Authentication failed: ${res.status} ${res.statusText}`);
}
const setCookie = res.headers.get("set-cookie");
if (setCookie) {
const match = setCookie.match(/dbx_session=([^;]+)/);
if (match) {
sessionCookie = match[1];
}
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> {
@ -43,10 +83,19 @@ function headers(extra?: Record<string, string>): Record<string, string> {
async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
await ensureAuth();
const res = await fetch(`${baseUrl}${path}`, {
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}`);
@ -54,6 +103,11 @@ async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
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();

View File

@ -0,0 +1,125 @@
import assert from "node:assert/strict";
import { afterEach, test } from "vitest";
import { 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(), []);
});