feat(mcp): run server from Rust binary

This commit is contained in:
t8y2 2026-07-18 17:57:44 +08:00
parent c19c4b9be6
commit 8d7dc695bb
35 changed files with 3492 additions and 100 deletions

View File

@ -51,7 +51,7 @@ jobs:
- name: Install native build dependencies
run: |
sudo apt-get update
sudo apt-get install -y libsecret-1-dev
sudo apt-get install -y libfontconfig1-dev libsecret-1-dev
- name: Install dependencies
run: pnpm install --frozen-lockfile
@ -77,12 +77,36 @@ jobs:
"packages/node-core/package.json",
"packages/cli/package.json",
"packages/mcp-server/package.json",
"packages/mcp-darwin-arm64/package.json",
"packages/mcp-darwin-x64/package.json",
"packages/mcp-linux-arm64-gnu/package.json",
"packages/mcp-linux-x64-gnu/package.json",
"packages/mcp-win32-arm64/package.json",
"packages/mcp-win32-x64/package.json",
]) {
const pkg = readJson(path);
pkg.version = version;
writeJson(path, pkg);
}
const mcpPackagePath = "packages/mcp-server/package.json";
const mcpPackage = readJson(mcpPackagePath);
for (const dependency of Object.keys(mcpPackage.optionalDependencies ?? {})) {
if (dependency.startsWith("@dbx-app/mcp-")) {
mcpPackage.optionalDependencies[dependency] = version;
}
}
writeJson(mcpPackagePath, mcpPackage);
const lockPath = "pnpm-lock.yaml";
let lockfile = fs.readFileSync(lockPath, "utf8");
for (const dependency of Object.keys(mcpPackage.optionalDependencies ?? {})) {
const escapedDependency = dependency.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const specifierPattern = new RegExp(`('${escapedDependency}':\\n\\s+specifier: )[^\\n]+`);
lockfile = lockfile.replace(specifierPattern, `$1${version}`);
}
fs.writeFileSync(lockPath, lockfile);
const serverPath = "packages/mcp-server/server.json";
const server = readJson(serverPath);
server.version = version;
@ -93,10 +117,17 @@ jobs:
}
writeJson(serverPath, server);
const cargoPath = "crates/dbx-mcp/Cargo.toml";
const cargo = fs.readFileSync(cargoPath, "utf8").replace(/^version = "[^"]+"/m, `version = "${version}"`);
fs.writeFileSync(cargoPath, cargo);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `version=${version}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `tag=packages-v${version}\n`);
NODE
- name: Update Rust lockfile
run: cargo check -p dbx-mcp --no-default-features
- name: Run package tests
run: pnpm test:packages
@ -111,7 +142,7 @@ jobs:
- name: Commit package release version
run: |
VERSION="${{ steps.version.outputs.version }}"
git add packages/mongo-shell/package.json packages/node-core/package.json packages/cli/package.json packages/mcp-server/package.json packages/mcp-server/server.json
git add Cargo.lock pnpm-lock.yaml crates/dbx-mcp/Cargo.toml packages/mongo-shell/package.json packages/node-core/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
@ -243,18 +274,10 @@ jobs:
pnpm --filter @dbx-app/node-core publish --access public --provenance --no-git-checks
echo "published_or_existing=true" >> "$GITHUB_OUTPUT"
publish-leaf-packages:
name: Publish ${{ matrix.package-name }}
publish-cli:
name: Publish @dbx-app/cli
runs-on: ubuntu-latest
needs: [prepare, publish-node-core]
strategy:
fail-fast: false
matrix:
include:
- package-name: "@dbx-app/cli"
filter: "@dbx-app/cli"
- package-name: "@dbx-app/mcp-server"
filter: "@dbx-app/mcp-server"
steps:
- uses: actions/checkout@v5
@ -287,19 +310,151 @@ jobs:
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
PACKAGE_NAME: ${{ matrix.package-name }}
PACKAGE_FILTER: ${{ matrix.filter }}
run: |
if npm view "${PACKAGE_NAME}@${VERSION}" version >/dev/null 2>&1; then
echo "${PACKAGE_NAME}@${VERSION} already exists on npm; skipping."
if npm view "@dbx-app/cli@${VERSION}" version >/dev/null 2>&1; then
echo "@dbx-app/cli@${VERSION} already exists on npm; skipping."
exit 0
fi
pnpm --filter "${PACKAGE_FILTER}" publish --access public --provenance --no-git-checks
pnpm --filter "@dbx-app/cli" publish --access public --provenance --no-git-checks
publish-mcp-platforms:
name: Publish ${{ matrix.package-name }}
needs: prepare
runs-on: ${{ matrix.runner }}
strategy:
fail-fast: false
matrix:
include:
- runner: macos-15
target: aarch64-apple-darwin
package-dir: mcp-darwin-arm64
package-name: "@dbx-app/mcp-darwin-arm64"
binary: dbx-mcp
- runner: macos-15-intel
target: x86_64-apple-darwin
package-dir: mcp-darwin-x64
package-name: "@dbx-app/mcp-darwin-x64"
binary: dbx-mcp
- runner: ubuntu-24.04-arm
target: aarch64-unknown-linux-gnu
package-dir: mcp-linux-arm64-gnu
package-name: "@dbx-app/mcp-linux-arm64-gnu"
binary: dbx-mcp
- runner: ubuntu-24.04
target: x86_64-unknown-linux-gnu
package-dir: mcp-linux-x64-gnu
package-name: "@dbx-app/mcp-linux-x64-gnu"
binary: dbx-mcp
- runner: windows-11-arm
target: aarch64-pc-windows-msvc
package-dir: mcp-win32-arm64
package-name: "@dbx-app/mcp-win32-arm64"
binary: dbx-mcp.exe
- runner: windows-2025
target: x86_64-pc-windows-msvc
package-dir: mcp-win32-x64
package-name: "@dbx-app/mcp-win32-x64"
binary: dbx-mcp.exe
steps:
- uses: actions/checkout@v5
with:
ref: ${{ needs.prepare.outputs.tag }}
- uses: dtolnay/rust-toolchain@stable
with:
targets: ${{ matrix.target }}
- uses: Swatinem/rust-cache@v2
with:
shared-key: dbx-mcp-${{ matrix.target }}
- name: Install Linux native dependencies
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libfontconfig1-dev
- name: Build Rust MCP binary
shell: bash
run: cargo build --release -p dbx-mcp --target "${{ matrix.target }}"
- name: Stage platform package
shell: bash
run: |
mkdir -p "packages/${{ matrix.package-dir }}/bin"
cp "target/${{ matrix.target }}/release/${{ matrix.binary }}" "packages/${{ matrix.package-dir }}/bin/${{ matrix.binary }}"
if [[ "${{ runner.os }}" != "Windows" ]]; then
chmod +x "packages/${{ matrix.package-dir }}/bin/${{ matrix.binary }}"
fi
- uses: actions/setup-node@v6
with:
node-version: 22.13.0
registry-url: https://registry.npmjs.org
- name: Publish platform package
shell: bash
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
if npm view "${{ matrix.package-name }}@${VERSION}" version >/dev/null 2>&1; then
echo "${{ matrix.package-name }}@${VERSION} already exists on npm; skipping."
exit 0
fi
npm publish "./packages/${{ matrix.package-dir }}" --access public --provenance
publish-mcp-server:
name: Publish @dbx-app/mcp-server
runs-on: ubuntu-latest
needs: [prepare, publish-mcp-platforms]
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 MCP launcher
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
VERSION: ${{ needs.prepare.outputs.version }}
run: |
for package in \
@dbx-app/mcp-darwin-arm64 \
@dbx-app/mcp-darwin-x64 \
@dbx-app/mcp-linux-arm64-gnu \
@dbx-app/mcp-linux-x64-gnu \
@dbx-app/mcp-win32-arm64 \
@dbx-app/mcp-win32-x64; do
npm view "${package}@${VERSION}" version >/dev/null 2>&1 || {
echo "${package}@${VERSION} is not available on npm; refusing to publish @dbx-app/mcp-server."
exit 1
}
done
if npm view "@dbx-app/mcp-server@${VERSION}" version >/dev/null 2>&1; then
echo "@dbx-app/mcp-server@${VERSION} already exists on npm; skipping."
exit 0
fi
pnpm --filter "@dbx-app/mcp-server" publish --access public --provenance --no-git-checks
publish-homebrew-formula:
name: Publish Homebrew formula
runs-on: ubuntu-latest
needs: [prepare, publish-leaf-packages]
needs: [prepare, publish-cli, publish-mcp-server]
steps:
- name: Download CLI npm tarball and compute SHA256
id: cli-hash

176
Cargo.lock generated
View File

@ -1909,6 +1909,24 @@ dependencies = [
"zip 4.6.1",
]
[[package]]
name = "dbx-mcp"
version = "0.4.33"
dependencies = [
"async-trait",
"dbx-core",
"json5",
"reqwest 0.12.28",
"rmcp",
"schemars 1.2.1",
"serde",
"serde_json",
"tempfile",
"tokio",
"url",
"uuid",
]
[[package]]
name = "dbx-web"
version = "0.5.60"
@ -2137,7 +2155,7 @@ dependencies = [
"libc",
"option-ext",
"redox_users",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -2478,7 +2496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -3563,7 +3581,7 @@ dependencies = [
"js-sys",
"log",
"wasm-bindgen",
"windows-core 0.57.0",
"windows-core 0.62.2",
]
[[package]]
@ -3985,6 +4003,17 @@ dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "json5"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1"
dependencies = [
"pest",
"pest_derive",
"serde",
]
[[package]]
name = "jsonptr"
version = "0.6.3"
@ -4628,7 +4657,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@ -4813,7 +4842,7 @@ version = "0.50.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -4913,7 +4942,7 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate 1.3.1",
"proc-macro-crate 3.5.0",
"proc-macro2",
"quote",
"syn 2.0.117",
@ -5275,7 +5304,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
dependencies = [
"libc",
"windows-sys 0.45.0",
"windows-sys 0.61.2",
]
[[package]]
@ -5418,6 +5447,12 @@ dependencies = [
"subtle",
]
[[package]]
name = "pastey"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2ee67f1008b1ba2321834326597b8e186293b049a023cdef258527550b9935b4"
[[package]]
name = "pathdiff"
version = "0.2.3"
@ -5497,6 +5532,48 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "pest"
version = "2.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47627dd7305c6a2d6c8c6bcd24c5a4c17dbbf425f4f9c5313e724b38fc9782e9"
dependencies = [
"memchr",
"ucd-trie",
]
[[package]]
name = "pest_derive"
version = "2.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b4254325ecad416ab689e27ba51da03ba01a9632bc6e108f5fe7c3c4ad29d58"
dependencies = [
"pest",
"pest_generator",
]
[[package]]
name = "pest_generator"
version = "2.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6c4c0e91ead7a8f7acecbca6f003fc2e8282b1dbe2dd9c9d2f16aba42995e0a7"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "pest_meta"
version = "2.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9744bc48116fee06334924bb5f2bad41eed5e89bd26e29b0b799f9a3f82c210"
dependencies = [
"pest",
]
[[package]]
name = "petgraph"
version = "0.8.3"
@ -6459,6 +6536,42 @@ dependencies = [
"syn 1.0.109",
]
[[package]]
name = "rmcp"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14db48ee17a9ba61810ab1a9c1beb7d06d8136ae39ac25a1137f10d357af01af"
dependencies = [
"async-trait",
"base64 0.22.1",
"chrono",
"futures",
"pastey",
"pin-project-lite",
"rmcp-macros",
"schemars 1.2.1",
"serde",
"serde_json",
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"tokio-util",
"tracing",
]
[[package]]
name = "rmcp-macros"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "783d787bf21813b285f13019adc49e11af501c658890c1e519f31f937c68b7e3"
dependencies = [
"darling",
"proc-macro2",
"quote",
"serde_json",
"syn 2.0.117",
]
[[package]]
name = "rsa"
version = "0.10.0-rc.16"
@ -6695,7 +6808,7 @@ dependencies = [
"errno",
"libc",
"linux-raw-sys 0.12.1",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -6796,7 +6909,7 @@ dependencies = [
"security-framework 3.7.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -6881,7 +6994,7 @@ checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615"
dependencies = [
"dyn-clone",
"indexmap 1.9.3",
"schemars_derive",
"schemars_derive 0.8.22",
"serde",
"serde_json",
"url",
@ -6906,8 +7019,10 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc"
dependencies = [
"chrono",
"dyn-clone",
"ref-cast",
"schemars_derive 1.2.1",
"serde",
"serde_json",
]
@ -6924,6 +7039,18 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "schemars_derive"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f"
dependencies = [
"proc-macro2",
"quote",
"serde_derive_internals",
"syn 2.0.117",
]
[[package]]
name = "scopeguard"
version = "1.2.0"
@ -7425,7 +7552,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
dependencies = [
"libc",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@ -7569,7 +7696,7 @@ dependencies = [
"cfg-if",
"libc",
"psm",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@ -8260,7 +8387,7 @@ dependencies = [
"getrandom 0.4.2",
"once_cell",
"rustix 1.1.4",
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]
@ -8561,6 +8688,17 @@ dependencies = [
"tokio",
]
[[package]]
name = "tokio-stream"
version = "0.1.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70"
dependencies = [
"futures-core",
"pin-project-lite",
"tokio",
]
[[package]]
name = "tokio-tungstenite"
version = "0.29.0"
@ -8846,7 +8984,7 @@ dependencies = [
"png 0.18.1",
"serde",
"thiserror 2.0.18",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@ -8920,6 +9058,12 @@ version = "1.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
[[package]]
name = "ucd-trie"
version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
[[package]]
name = "uds_windows"
version = "1.2.1"
@ -8928,7 +9072,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
dependencies = [
"memoffset",
"tempfile",
"windows-sys 0.60.2",
"windows-sys 0.61.2",
]
[[package]]
@ -9593,7 +9737,7 @@ version = "0.1.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
dependencies = [
"windows-sys 0.59.0",
"windows-sys 0.61.2",
]
[[package]]

View File

@ -1,6 +1,6 @@
[workspace]
resolver = "2"
members = ["src-tauri", "crates/dbx-core", "crates/dbx-web"]
members = ["src-tauri", "crates/dbx-core", "crates/dbx-web", "crates/dbx-mcp"]
[patch.crates-io]
tokio-postgres = { git = "https://github.com/t8y2/tokio-postgres-gaussdb.git", branch = "master" }

25
crates/dbx-mcp/Cargo.toml Normal file
View File

@ -0,0 +1,25 @@
[package]
name = "dbx-mcp"
version = "0.4.33"
edition = "2021"
license = "Apache-2.0"
[[bin]]
name = "dbx-mcp"
path = "src/main.rs"
[dependencies]
async-trait = "0.1"
dbx-core = { path = "../dbx-core", default-features = false }
rmcp = { version = "2.2.0", features = ["client", "transport-io"] }
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
json5 = "0.4"
schemars = "1"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
tokio = { version = "1", features = ["fs", "macros", "rt-multi-thread"] }
uuid = { version = "1", features = ["v4"] }
url = "2"
[dev-dependencies]
tempfile = "3"

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
pub mod backend;
pub mod mongo;
pub mod paths;
pub mod server;
pub use backend::{ConnectionSummary, DbxBackend, LocalBackend, WebBackend};
pub use server::{DbxMcpServer, McpScope};

View File

@ -0,0 +1,20 @@
use std::sync::Arc;
use dbx_mcp::{DbxBackend, DbxMcpServer, LocalBackend, WebBackend};
use rmcp::ServiceExt;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let backend: Arc<dyn DbxBackend> = if let Ok(base_url) = std::env::var("DBX_WEB_URL") {
Arc::new(
WebBackend::new(base_url, std::env::var("DBX_WEB_PASSWORD").unwrap_or_default())
.map_err(std::io::Error::other)?,
)
} else {
let db_path = dbx_mcp::paths::storage_db_path().map_err(std::io::Error::other)?;
Arc::new(LocalBackend::open(&db_path).await.map_err(std::io::Error::other)?)
};
let service = DbxMcpServer::new(backend).serve(rmcp::transport::stdio()).await?;
service.waiting().await?;
Ok(())
}

491
crates/dbx-mcp/src/mongo.rs Normal file
View File

@ -0,0 +1,491 @@
use serde_json::Value;
#[derive(Debug, Clone, PartialEq)]
pub enum MongoCommand {
Version,
Find { collection: String, filter: String, projection: Option<String>, sort: Option<String>, skip: u64, limit: i64 },
Count { collection: String, filter: String, accurate: bool },
Aggregate { collection: String, pipeline: String, options: Option<String> },
Distinct { collection: String, field: String, filter: Option<String> },
GetIndexes { collection: String },
CollectionStats { collection: String, metric: String, scale: Option<serde_json::Number> },
Insert { collection: String, documents: String },
Update { collection: String, filter: String, update: String, options: Option<String>, many: bool },
Delete { collection: String, filter: String, many: bool },
CreateIndex { collection: String, keys: String, options: Option<String> },
DropIndexes { collection: String, indexes: Option<String>, single: bool },
DropCollection { collection: String },
}
impl MongoCommand {
pub fn is_mutating(&self) -> bool {
matches!(
self,
Self::Insert { .. }
| Self::Update { .. }
| Self::Delete { .. }
| Self::CreateIndex { .. }
| Self::DropIndexes { .. }
| Self::DropCollection { .. }
) || matches!(self, Self::Aggregate { pipeline, .. } if aggregate_writes(pipeline))
}
pub fn is_dangerous(&self) -> bool {
matches!(self, Self::DropCollection { .. })
|| matches!(self, Self::DropIndexes { indexes: None, single: false, .. })
|| matches!(self, Self::Aggregate { pipeline, .. } if aggregate_writes(pipeline))
}
pub fn has_empty_filter(&self) -> bool {
match self {
Self::Update { filter, .. } | Self::Delete { filter, .. } => is_empty_object(filter),
_ => false,
}
}
}
pub fn parse(input: &str) -> Result<MongoCommand, String> {
let source = input.trim().trim_end_matches(';').trim();
if source.eq_ignore_ascii_case("db.version()") {
return Ok(MongoCommand::Version);
}
let (collection, prefix_end) = parse_collection_prefix(source)?;
if let Some((args, tail)) = method_call(source, prefix_end, "find") {
let filter = normalized_json(args.first().map(String::as_str).unwrap_or("{}"))?;
let projection =
if args.get(1).is_some_and(|arg| !arg.trim().is_empty()) { Some(normalized_json(&args[1])?) } else { None };
if args.len() > 2 {
return Err("MongoDB find() accepts at most filter and projection arguments.".to_string());
}
let mut sort = None;
let mut skip = 0;
let mut limit = 100;
for (name, call_args) in chained_calls(&tail)? {
match name.as_str() {
"sort" => sort = Some(normalized_json(call_args.first().map(String::as_str).unwrap_or("{}"))?),
"skip" => skip = parse_integer(&call_args, "skip")? as u64,
"limit" => limit = parse_integer(&call_args, "limit")?,
"count" if call_args.is_empty() => {
return Ok(MongoCommand::Count { collection, filter, accurate: false });
}
_ => return Err(format!("Unsupported MongoDB find() chain: {name}()")),
}
}
return Ok(MongoCommand::Find { collection, filter, projection, sort, skip, limit });
}
for (method, accurate) in [("countDocuments", true), ("count", false)] {
if let Some((args, tail)) = method_call(source, prefix_end, method) {
if !tail.is_empty() || args.len() > 1 {
return Err(format!("Invalid MongoDB {method}() command."));
}
return Ok(MongoCommand::Count {
collection,
filter: normalized_json(args.first().map(String::as_str).unwrap_or("{}"))?,
accurate,
});
}
}
if let Some((args, tail)) = method_call(source, prefix_end, "aggregate") {
if !tail.is_empty() || !(1..=2).contains(&args.len()) {
return Err("Invalid MongoDB aggregate() command.".to_string());
}
let pipeline = normalized_json(&args[0])?;
if !parse_json_value(&pipeline).is_some_and(|value| value.is_array()) {
return Err("MongoDB aggregate() requires a pipeline array.".to_string());
}
let options = args.get(1).filter(|arg| !arg.trim().is_empty()).map(|arg| normalized_json(arg)).transpose()?;
return Ok(MongoCommand::Aggregate { collection, pipeline, options });
}
if let Some((args, tail)) = method_call(source, prefix_end, "distinct") {
if !tail.is_empty() || !(1..=2).contains(&args.len()) {
return Err("Invalid MongoDB distinct() command.".to_string());
}
let field = parse_string_arg(&args[0])?;
let filter = args.get(1).filter(|arg| !arg.trim().is_empty()).map(|arg| normalized_json(arg)).transpose()?;
return Ok(MongoCommand::Distinct { collection, field, filter });
}
if let Some((args, tail)) = method_call(source, prefix_end, "getIndexes") {
if !tail.is_empty() || !args.is_empty() {
return Err("Invalid MongoDB getIndexes() command.".to_string());
}
return Ok(MongoCommand::GetIndexes { collection });
}
for metric in ["stats", "dataSize", "storageSize", "totalIndexSize"] {
if let Some((args, tail)) = method_call(source, prefix_end, metric) {
if !tail.is_empty() || args.len() > 1 {
return Err(format!("Invalid MongoDB {metric}() command."));
}
let scale = args
.first()
.filter(|arg| !arg.trim().is_empty())
.map(|arg| {
arg.trim()
.parse::<f64>()
.ok()
.and_then(serde_json::Number::from_f64)
.ok_or_else(|| format!("Invalid {metric} scale."))
})
.transpose()?;
return Ok(MongoCommand::CollectionStats { collection, metric: metric.to_string(), scale });
}
}
if let Some((args, tail)) = method_call(source, prefix_end, "insertOne") {
if !tail.is_empty() || args.len() != 1 {
return Err("Invalid MongoDB insertOne() command.".to_string());
}
return Ok(MongoCommand::Insert { collection, documents: normalized_json(&args[0])? });
}
if let Some((args, tail)) = method_call(source, prefix_end, "insertMany") {
if !tail.is_empty() || args.len() != 1 {
return Err("Invalid MongoDB insertMany() command.".to_string());
}
let documents = normalized_json(&args[0])?;
if !parse_json_value(&documents).is_some_and(|value| value.is_array()) {
return Err("MongoDB insertMany() requires an array.".to_string());
}
return Ok(MongoCommand::Insert { collection, documents });
}
for (method, many) in [("updateOne", false), ("updateMany", true)] {
if let Some((args, tail)) = method_call(source, prefix_end, method) {
if !tail.is_empty() || !(2..=3).contains(&args.len()) {
return Err(format!("Invalid MongoDB {method}() command."));
}
return Ok(MongoCommand::Update {
collection,
filter: normalized_json(&args[0])?,
update: normalized_json(&args[1])?,
options: args
.get(2)
.filter(|arg| !arg.trim().is_empty())
.map(|arg| normalized_json(arg))
.transpose()?,
many,
});
}
}
for (method, many) in [("deleteOne", false), ("deleteMany", true)] {
if let Some((args, tail)) = method_call(source, prefix_end, method) {
if !tail.is_empty() || args.len() != 1 {
return Err(format!("Invalid MongoDB {method}() command."));
}
return Ok(MongoCommand::Delete { collection, filter: normalized_json(&args[0])?, many });
}
}
if let Some((args, tail)) = method_call(source, prefix_end, "createIndex") {
if !tail.is_empty() || !(1..=2).contains(&args.len()) {
return Err("Invalid MongoDB createIndex() command.".to_string());
}
return Ok(MongoCommand::CreateIndex {
collection,
keys: normalized_json(&args[0])?,
options: args.get(1).filter(|arg| !arg.trim().is_empty()).map(|arg| normalized_json(arg)).transpose()?,
});
}
if let Some((args, tail)) = method_call(source, prefix_end, "dropIndex") {
if !tail.is_empty() || args.len() != 1 {
return Err("Invalid MongoDB dropIndex() command.".to_string());
}
return Ok(MongoCommand::DropIndexes { collection, indexes: Some(normalized_json(&args[0])?), single: true });
}
if let Some((args, tail)) = method_call(source, prefix_end, "dropIndexes") {
if !tail.is_empty() || args.len() > 1 {
return Err("Invalid MongoDB dropIndexes() command.".to_string());
}
return Ok(MongoCommand::DropIndexes {
collection,
indexes: args.first().filter(|arg| !arg.trim().is_empty()).map(|arg| normalized_json(arg)).transpose()?,
single: false,
});
}
if let Some((args, tail)) = method_call(source, prefix_end, "drop") {
if !tail.is_empty() || !args.is_empty() {
return Err("Invalid MongoDB drop() command.".to_string());
}
return Ok(MongoCommand::DropCollection { collection });
}
Err("Unsupported MongoDB shell command.".to_string())
}
fn parse_collection_prefix(source: &str) -> Result<(String, usize), String> {
if !source.get(..3).is_some_and(|prefix| prefix.eq_ignore_ascii_case("db.")) {
return Err("MongoDB command must start with db.<collection>.".to_string());
}
let rest = &source[3..];
if rest.starts_with("getCollection") {
let open = rest.find('(').ok_or("Invalid db.getCollection() command.")?;
let close = matching_paren(rest, open).ok_or("Invalid db.getCollection() command.")?;
let args = split_top_level(&rest[open + 1..close]);
if args.len() != 1 {
return Err("db.getCollection() requires one collection name.".to_string());
}
let collection = parse_string_arg(&args[0])?;
let end = 3 + close + 1;
let suffix = &source[end..];
let trimmed = suffix.trim_start();
if !trimmed.starts_with('.') {
return Err("MongoDB collection method is required.".to_string());
}
return Ok((collection, end + suffix.len() - trimmed.len()));
}
let collection_end = rest
.char_indices()
.find_map(|(index, ch)| (ch == '.' || ch.is_whitespace()).then_some(index))
.ok_or("MongoDB collection method is required.")?;
let collection = &rest[..collection_end];
if collection.is_empty() {
return Err("Invalid MongoDB collection name.".to_string());
}
let suffix = &rest[collection_end..];
let dot = suffix.find('.').ok_or("MongoDB collection method is required.")?;
if !suffix[..dot].trim().is_empty() {
return Err("Invalid MongoDB collection name.".to_string());
}
Ok((collection.to_string(), 3 + collection_end + dot))
}
fn method_call(source: &str, prefix_end: usize, method: &str) -> Option<(Vec<String>, String)> {
let raw_suffix = &source[prefix_end..];
let suffix = raw_suffix.trim_start();
let whitespace = raw_suffix.len() - suffix.len();
let expected = format!(".{method}");
if !suffix.starts_with(&expected) || !suffix[expected.len()..].starts_with('(') {
return None;
}
let open = prefix_end + whitespace + expected.len();
let close = matching_paren(source, open)?;
Some((split_top_level(&source[open + 1..close]), source[close + 1..].trim().to_string()))
}
fn chained_calls(chain: &str) -> Result<Vec<(String, Vec<String>)>, String> {
let mut rest = chain.trim();
let mut calls = Vec::new();
while !rest.is_empty() {
let Some(rest_after_dot) = rest.strip_prefix('.') else {
return Err("Invalid MongoDB method chain.".to_string());
};
let open = rest_after_dot.find('(').ok_or("Invalid MongoDB method chain.")?;
let name = rest_after_dot[..open].trim().to_string();
let close = matching_paren(rest_after_dot, open).ok_or("Invalid MongoDB method chain.")?;
calls.push((name, split_top_level(&rest_after_dot[open + 1..close])));
rest = rest_after_dot[close + 1..].trim();
}
Ok(calls)
}
fn parse_integer(args: &[String], name: &str) -> Result<i64, String> {
if args.len() != 1 {
return Err(format!("MongoDB {name}() requires one integer."));
}
let value =
args[0].trim().parse::<i64>().map_err(|_| format!("MongoDB {name}() requires a non-negative integer."))?;
if value < 0 {
return Err(format!("MongoDB {name}() requires a non-negative integer."));
}
Ok(value)
}
fn parse_string_arg(arg: &str) -> Result<String, String> {
let value = parse_json_value(&normalized_json(arg)?).ok_or("Invalid MongoDB string argument.")?;
value.as_str().map(ToOwned::to_owned).ok_or_else(|| "MongoDB argument must be a string.".to_string())
}
fn normalized_json(input: &str) -> Result<String, String> {
let transformed = transform_shell_constructors(input.trim())?;
let value: Value =
json5::from_str(&transformed).map_err(|error| format!("Invalid MongoDB JSON argument: {error}"))?;
serde_json::to_string(&value).map_err(|error| error.to_string())
}
fn transform_shell_constructors(input: &str) -> Result<String, String> {
let mut output = String::with_capacity(input.len());
let mut index = 0;
while index < input.len() {
let rest = &input[index..];
let constructor = if rest.starts_with("ObjectId(") {
Some("ObjectId(")
} else if rest.starts_with("ISODate(") {
Some("ISODate(")
} else {
None
};
let Some(constructor) = constructor else {
let ch = rest.chars().next().ok_or("Invalid MongoDB argument.")?;
output.push(ch);
index += ch.len_utf8();
continue;
};
let open = index + constructor.len() - 1;
let close = matching_paren(input, open).ok_or("Unclosed MongoDB value constructor.")?;
let inner = input[open + 1..close].trim();
let value = parse_string_arg(inner)?;
let key = if constructor.starts_with("ObjectId") { "$oid" } else { "$date" };
output.push_str(&format!("{{\"{key}\":{}}}", serde_json::to_string(&value).unwrap()));
index = close + 1;
}
Ok(output)
}
fn parse_json_value(value: &str) -> Option<Value> {
serde_json::from_str(value).ok()
}
fn is_empty_object(value: &str) -> bool {
parse_json_value(value).is_some_and(|value| value.as_object().is_some_and(|object| object.is_empty()))
}
fn aggregate_writes(pipeline: &str) -> bool {
parse_json_value(pipeline).is_some_and(|value| {
value.as_array().is_some_and(|stages| {
stages.iter().any(|stage| {
stage
.as_object()
.is_some_and(|object| object.keys().any(|key| matches!(key.as_str(), "$out" | "$merge")))
})
})
})
}
fn matching_paren(source: &str, open: usize) -> Option<usize> {
let bytes = source.as_bytes();
let mut depth = 0;
let mut quote = None;
let mut escape = false;
for (index, byte) in bytes.iter().enumerate().skip(open) {
let ch = *byte as char;
if escape {
escape = false;
continue;
}
if quote.is_some() {
if ch == '\\' {
escape = true;
} else if Some(ch) == quote {
quote = None;
}
continue;
}
if ch == '\'' || ch == '"' || ch == '`' {
quote = Some(ch);
} else if ch == '(' {
depth += 1;
} else if ch == ')' {
depth -= 1;
if depth == 0 {
return Some(index);
}
}
}
None
}
fn split_top_level(source: &str) -> Vec<String> {
if source.trim().is_empty() {
return Vec::new();
}
let mut result = Vec::new();
let mut start = 0;
let mut depth = 0;
let mut quote = None;
let mut escape = false;
for (index, byte) in source.as_bytes().iter().enumerate() {
let ch = *byte as char;
if escape {
escape = false;
continue;
}
if quote.is_some() {
if ch == '\\' {
escape = true;
} else if Some(ch) == quote {
quote = None;
}
continue;
}
if ch == '\'' || ch == '"' || ch == '`' {
quote = Some(ch);
} else if matches!(ch, '(' | '[' | '{') {
depth += 1;
} else if matches!(ch, ')' | ']' | '}') {
depth -= 1;
} else if ch == ',' && depth == 0 {
result.push(source[start..index].trim().to_string());
start = index + 1;
}
}
result.push(source[start..].trim().to_string());
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_find_with_compass_syntax_and_chain() {
assert_eq!(
parse("db.products.find({_id: ObjectId('507f1f77bcf86cd799439011')}, {title: 1, _id: 0}).sort({title: 1}).limit(1)").unwrap(),
MongoCommand::Find {
collection: "products".to_string(),
filter: r#"{"_id":{"$oid":"507f1f77bcf86cd799439011"}}"#.to_string(),
projection: Some(r#"{"title":1,"_id":0}"#.to_string()),
sort: Some(r#"{"title":1}"#.to_string()),
skip: 0,
limit: 1,
}
);
}
#[test]
fn parses_get_collection_and_count() {
assert_eq!(
parse("db.getCollection('audit.logs').count()").unwrap(),
MongoCommand::Count { collection: "audit.logs".to_string(), filter: "{}".to_string(), accurate: false }
);
}
#[test]
fn identifies_dangerous_aggregate_and_empty_writes() {
let aggregate = parse(r#"db.projects.aggregate([{"$out":"backup"}])"#).unwrap();
assert!(aggregate.is_mutating());
assert!(aggregate.is_dangerous());
let update = parse("db.projects.updateMany({}, {$set: {active: false}})").unwrap();
assert!(update.has_empty_filter());
}
#[test]
fn accepts_multiline_chains_and_update_options() {
let command = parse(
r#"db.getCollection("operation_logs")
.find({_id: ObjectId("68ad51ca84c8127bc7d44cb3")})
.sort({ts: -1})
.skip(5)
.limit(10)"#,
)
.unwrap();
assert!(matches!(command, MongoCommand::Find { skip: 5, limit: 10, .. }));
let update = parse(
r#"db.orders.updateMany({status: "open"}, {$set: {"items.$[item].status": "done"}}, {arrayFilters: [{"item.id": 7}]})"#,
)
.unwrap();
assert!(matches!(update, MongoCommand::Update { many: true, options: Some(_), .. }));
}
#[test]
fn accepts_stats_and_rejects_negative_pagination() {
assert!(matches!(
parse("db.users.stats(1024)").unwrap(),
MongoCommand::CollectionStats { metric, scale: Some(_), .. } if metric == "stats"
));
assert!(parse("db.users.find({}).skip(-1)").is_err());
}
}

View File

@ -0,0 +1,46 @@
use std::path::PathBuf;
pub const STORAGE_DB_FILE_NAME: &str = "dbx.db";
pub fn app_data_dir() -> Result<PathBuf, String> {
if let Some(path) = std::env::var_os("DBX_DATA_DIR").filter(|value| !value.is_empty()) {
return Ok(PathBuf::from(path));
}
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.ok_or_else(|| "Unable to resolve the user home directory. Set DBX_DATA_DIR explicitly.".to_string())?;
#[cfg(target_os = "macos")]
return Ok(home.join("Library/Application Support/com.dbx.app"));
#[cfg(target_os = "windows")]
return Ok(std::env::var_os("APPDATA")
.map(PathBuf::from)
.unwrap_or_else(|| home.join("AppData/Roaming"))
.join("com.dbx.app"));
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
return Ok(home.join(".local/share/com.dbx.app"));
}
pub fn storage_db_path() -> Result<PathBuf, String> {
Ok(app_data_dir()?.join(STORAGE_DB_FILE_NAME))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn explicit_data_dir_wins() {
let original = std::env::var_os("DBX_DATA_DIR");
std::env::set_var("DBX_DATA_DIR", "/tmp/dbx-mcp-data");
assert_eq!(app_data_dir().unwrap(), PathBuf::from("/tmp/dbx-mcp-data"));
match original {
Some(value) => std::env::set_var("DBX_DATA_DIR", value),
None => std::env::remove_var("DBX_DATA_DIR"),
}
}
}

View File

@ -0,0 +1,898 @@
use std::sync::Arc;
use rmcp::{
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{CallToolResult, ContentBlock, Implementation, ServerCapabilities, ServerInfo},
schemars, tool, tool_handler, tool_router, ServerHandler,
};
use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
use crate::backend::{new_connection_config, parse_database_type, ConnectionSummary, DbxBackend};
use crate::mongo::{self, MongoCommand};
use dbx_core::{
db::redis_driver::{classify_command, parse_command_argv, RedisCommandResult, RedisCommandSafety},
models::connection::DatabaseType,
production_safety::{is_production_database, targets_production_database},
sql_risk::{classify_sql_risk_for_database, SqlRisk},
};
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ListConnectionsRequest {}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ConnectionSelector {
#[schemars(description = "Unique ID of the DBX connection")]
pub connection_id: Option<String>,
#[schemars(description = "Name of the DBX connection")]
pub connection_name: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ListTablesRequest {
#[serde(flatten)]
pub selector: ConnectionSelector,
#[schemars(description = "Database name")]
pub database: Option<String>,
#[schemars(description = "Schema name")]
pub schema: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct DescribeTableRequest {
#[serde(flatten)]
pub selector: ConnectionSelector,
#[schemars(description = "Table name")]
pub table: String,
#[schemars(description = "Database name")]
pub database: Option<String>,
#[schemars(description = "Schema name")]
pub schema: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ExecuteQueryRequest {
#[serde(flatten)]
pub selector: ConnectionSelector,
#[schemars(description = "Database name")]
pub database: Option<String>,
#[schemars(description = "SQL query to execute")]
pub sql: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct AddConnectionRequest {
pub name: String,
pub db_type: String,
pub host: String,
pub port: Option<u16>,
#[serde(default)]
pub username: String,
#[serde(default)]
pub password: String,
pub database: Option<String>,
#[serde(default)]
pub ssl: bool,
pub driver_profile: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct RemoveConnectionRequest {
pub connection_name: String,
pub connection_id: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ExecuteRedisCommandRequest {
#[serde(flatten)]
pub selector: ConnectionSelector,
#[schemars(description = "Redis logical database number")]
pub db: Option<u32>,
#[schemars(description = "Redis command to execute, for example GET mykey or INFO")]
pub command: String,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct SchemaContextRequest {
#[serde(flatten)]
pub selector: ConnectionSelector,
pub database: Option<String>,
pub schema: Option<String>,
#[schemars(description = "Specific table names to include")]
pub tables: Option<Vec<String>>,
#[schemars(description = "Maximum number of tables to include, from 1 to 20")]
pub max_tables: Option<usize>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct OpenTableRequest {
#[serde(flatten)]
pub selector: ConnectionSelector,
pub table: String,
pub database: Option<String>,
pub schema: Option<String>,
}
#[derive(Debug, Deserialize, schemars::JsonSchema)]
pub struct ExecuteAndShowRequest {
#[serde(flatten)]
pub selector: ConnectionSelector,
pub sql: String,
pub database: Option<String>,
}
#[derive(Clone)]
pub struct DbxMcpServer {
backend: Arc<dyn DbxBackend>,
scope: McpScope,
tool_router: ToolRouter<Self>,
}
#[derive(Clone, Debug, Default)]
pub struct McpScope {
pub connection_id: Option<String>,
pub connection_name: Option<String>,
pub database: Option<String>,
}
impl McpScope {
pub fn from_env() -> Self {
Self {
connection_id: non_empty_env("DBX_MCP_SCOPE_CONNECTION_ID"),
connection_name: non_empty_env("DBX_MCP_SCOPE_CONNECTION_NAME"),
database: non_empty_env("DBX_MCP_SCOPE_DATABASE"),
}
}
fn enabled(&self) -> bool {
self.connection_id.is_some() || self.connection_name.is_some()
}
fn matches(&self, connection: &dbx_core::models::connection::ConnectionConfig) -> bool {
self.connection_id.as_deref() == Some(connection.id.as_str())
|| self.connection_name.as_deref() == Some(connection.name.as_str())
}
}
impl DbxMcpServer {
pub fn new(backend: Arc<dyn DbxBackend>) -> Self {
Self::with_runtime_options(backend, McpScope::from_env(), std::env::var_os("DBX_WEB_URL").is_some())
}
pub fn with_runtime_options(backend: Arc<dyn DbxBackend>, scope: McpScope, web_mode: bool) -> Self {
let mut tool_router = Self::tool_router();
if scope.enabled() {
tool_router.disable_route("dbx_add_connection");
tool_router.disable_route("dbx_remove_connection");
}
// Desktop UI bridge operations are intentionally unavailable remotely and in scoped AI sessions.
if web_mode || scope.enabled() {
tool_router.disable_route("dbx_open_table");
tool_router.disable_route("dbx_execute_and_show");
}
Self { backend, scope, tool_router }
}
}
#[tool_router]
impl DbxMcpServer {
#[tool(
name = "dbx_list_connections",
description = "List database connections configured in DBX. Returns connection IDs, names, database types, endpoints, and selected databases."
)]
async fn list_connections(
&self,
Parameters(ListConnectionsRequest {}): Parameters<ListConnectionsRequest>,
) -> CallToolResult {
match self.load_scoped_connections().await {
Ok(connections) if connections.is_empty() => text("No connections configured in DBX."),
Ok(connections) => {
let rows = connections.iter().map(ConnectionSummary::from).collect::<Vec<_>>();
text(format_connections(&rows))
}
Err(error) => tool_error("CONNECTION_LOAD_ERROR", error),
}
}
#[tool(name = "dbx_list_tables", description = "List tables and views for a database connection")]
async fn list_tables(&self, Parameters(request): Parameters<ListTablesRequest>) -> CallToolResult {
let connection = match self.resolve_connection(&request.selector).await {
Ok(connection) => connection,
Err(error) => return error,
};
let database = self.resolve_database(request.database, &connection);
match self.backend.list_tables(&connection, &database, &request.schema.unwrap_or_default()).await {
Ok(tables) if tables.is_empty() => text("No tables found."),
Ok(tables) => text(
tables
.into_iter()
.map(|table| {
let comment = table
.comment
.filter(|comment| !comment.is_empty())
.map(|comment| format!(" -- {comment}"))
.unwrap_or_default();
format!("- {} ({}){}", table.name, table.table_type, comment)
})
.collect::<Vec<_>>()
.join("\n"),
),
Err(error) => tool_error("TABLE_LIST_ERROR", error),
}
}
#[tool(name = "dbx_describe_table", description = "Get column definitions for a table")]
async fn describe_table(&self, Parameters(request): Parameters<DescribeTableRequest>) -> CallToolResult {
let connection = match self.resolve_connection(&request.selector).await {
Ok(connection) => connection,
Err(error) => return error,
};
let database = self.resolve_database(request.database, &connection);
match self
.backend
.get_columns(&connection, &database, &request.schema.unwrap_or_default(), &request.table)
.await
{
Ok(columns) if columns.is_empty() => text("No columns found."),
Ok(columns) => text(format_columns(&columns)),
Err(error) => tool_error("TABLE_DESCRIPTION_ERROR", error),
}
}
#[tool(
name = "dbx_execute_query",
description = "Execute a SQL query on a database connection (max 100 rows returned)"
)]
async fn execute_query(&self, Parameters(request): Parameters<ExecuteQueryRequest>) -> CallToolResult {
let connection = match self.resolve_connection(&request.selector).await {
Ok(connection) => connection,
Err(error) => return error,
};
if connection.db_type == dbx_core::models::connection::DatabaseType::Redis {
return tool_error(
"REDIS_COMMAND_REQUIRED",
"Redis connections do not accept SQL through dbx_execute_query. Use dbx_execute_redis_command.",
);
}
let database = self.resolve_database(request.database, &connection);
if connection.db_type == DatabaseType::MongoDb {
let command = match validate_mongo_command(&connection, &database, &request.sql) {
Ok(command) => command,
Err(error) => return error,
};
return match self.backend.execute_mongo_command(&connection, &database, &command).await {
Ok(result) => text(result),
Err(error) => tool_error("QUERY_ERROR", error),
};
}
let result = self
.backend
.execute_agent_tool(
&connection,
&database,
"execute_query",
json!({ "sql": request.sql, "limit": 100 }),
default_permissions(),
)
.await;
agent_result(result)
}
#[tool(name = "dbx_execute_redis_command", description = "Execute a Redis command on a Redis connection")]
async fn execute_redis_command(
&self,
Parameters(request): Parameters<ExecuteRedisCommandRequest>,
) -> CallToolResult {
let connection = match self.resolve_connection(&request.selector).await {
Ok(connection) => connection,
Err(error) => return error,
};
if connection.db_type != DatabaseType::Redis {
return tool_error("INVALID_CONNECTION_TYPE", format!("Connection \"{}\" is not Redis.", connection.name));
}
let argv = match parse_command_argv(&request.command) {
Ok(argv) => argv,
Err(error) => return tool_error("REDIS_COMMAND_BLOCKED", error),
};
let safety = classify_command(&argv[0]);
let permissions = default_permissions();
if safety == RedisCommandSafety::Blocked && !permissions.allow_dangerous {
return tool_error(
"REDIS_COMMAND_BLOCKED",
format!(
"Dangerous Redis command \"{}\" is blocked. Set DBX_MCP_ALLOW_DANGEROUS_SQL=1 to allow it.",
argv[0].to_ascii_uppercase()
),
);
}
if safety != RedisCommandSafety::Allowed && !permissions.allow_writes {
return tool_error(
"REDIS_COMMAND_BLOCKED",
"MCP Redis command execution is read-only for this session. Set DBX_MCP_ALLOW_WRITES=1 to allow write or dangerous commands.",
);
}
let database = request
.db
.or_else(|| self.scope.database.as_deref().and_then(parse_redis_database))
.or_else(|| redis_database(&connection))
.unwrap_or(0);
// Production protection is stricter than the opt-in write flags by design.
if safety != RedisCommandSafety::Allowed && is_production_database(&connection, &database.to_string()) {
return tool_error(
"PRODUCTION_WRITE_BLOCKED",
"MCP cannot execute write or dangerous Redis commands against a production database.",
);
}
match self
.backend
.execute_redis_command(
&connection,
database,
&request.command,
safety == RedisCommandSafety::Blocked && permissions.allow_dangerous,
)
.await
{
Ok(result) => text(format_redis_result(&result)),
Err(error) => tool_error("REDIS_COMMAND_ERROR", error),
}
}
#[tool(name = "dbx_get_schema_context", description = "Get compact table and column context for writing SQL")]
async fn get_schema_context(&self, Parameters(request): Parameters<SchemaContextRequest>) -> CallToolResult {
let connection = match self.resolve_connection(&request.selector).await {
Ok(connection) => connection,
Err(error) => return error,
};
let database = self.resolve_database(request.database, &connection);
let schema = request.schema.unwrap_or_default();
let max_tables = request.max_tables.unwrap_or(8).clamp(1, 20);
let available = match self.backend.list_tables(&connection, &database, &schema).await {
Ok(tables) => tables,
Err(error) => return tool_error("SCHEMA_CONTEXT_ERROR", error),
};
let requested = request
.tables
.unwrap_or_default()
.into_iter()
.map(|name| name.to_ascii_lowercase())
.collect::<std::collections::HashSet<_>>();
let mut selected = if requested.is_empty() {
available.iter().collect::<Vec<_>>()
} else {
available.iter().filter(|table| requested.contains(&table.name.to_ascii_lowercase())).collect::<Vec<_>>()
};
let truncated = selected.len() > max_tables || (requested.is_empty() && available.len() > max_tables);
selected.truncate(max_tables);
if selected.is_empty() {
return text("No matching tables found.");
}
let mut tables = Vec::with_capacity(selected.len());
for table in selected {
// Keep metadata calls sequential because some embedded drivers expose a single physical connection.
let columns = match self.backend.get_columns(&connection, &database, &schema, &table.name).await {
Ok(columns) => columns,
Err(error) => return tool_error("SCHEMA_CONTEXT_ERROR", error),
};
tables.push((table.clone(), columns));
}
text(format_schema_context(&connection.name, &database, &schema, &tables, truncated))
}
#[tool(name = "dbx_add_connection", description = "Add a new database connection to DBX")]
async fn add_connection(&self, Parameters(request): Parameters<AddConnectionRequest>) -> CallToolResult {
let mut connections = match self.backend.load_connections().await {
Ok(connections) => connections,
Err(error) => return tool_error("CONNECTION_LOAD_ERROR", error),
};
if connections.iter().any(|connection| connection.name.eq_ignore_ascii_case(&request.name)) {
return text(format!("Connection \"{}\" already exists.", request.name));
}
let db_type = match parse_database_type(&request.db_type) {
Ok(db_type) => db_type,
Err(error) => return tool_error("INVALID_CONNECTION_TYPE", error),
};
let port = match request.port.or_else(|| default_port(&request.db_type)) {
Some(port) => port,
None => return text("Port is required for this database type."),
};
let config = match new_connection_config(
Uuid::new_v4().to_string(),
request.name,
db_type,
request.host,
port,
request.username,
request.password,
request.database,
request.ssl,
request.driver_profile,
) {
Ok(config) => config,
Err(error) => return tool_error("INVALID_CONNECTION", error),
};
connections.push(config.clone());
if let Err(error) = self.backend.save_connections(&connections).await {
return tool_error("CONNECTION_SAVE_ERROR", error);
}
text(format!("Connection \"{}\" added (id: {}).", config.name, config.id))
}
#[tool(name = "dbx_remove_connection", description = "Remove a database connection from DBX")]
async fn remove_connection(&self, Parameters(request): Parameters<RemoveConnectionRequest>) -> CallToolResult {
let mut connections = match self.backend.load_connections().await {
Ok(connections) => connections,
Err(error) => return tool_error("CONNECTION_LOAD_ERROR", error),
};
let target = if let Some(id) = request.connection_id.as_deref().map(str::trim).filter(|id| !id.is_empty()) {
connections.iter().find(|connection| connection.id == id).cloned()
} else {
let matching = connections
.iter()
.filter(|connection| connection.name.eq_ignore_ascii_case(&request.connection_name))
.cloned()
.collect::<Vec<_>>();
if matching.len() > 1 {
return tool_error("AMBIGUOUS_CONNECTION", ambiguous_connections(&request.connection_name, &matching));
}
matching.into_iter().next()
};
let Some(target) = target else {
return tool_error(
"CONNECTION_NOT_FOUND",
format!("Connection \"{}\" not found.", request.connection_name),
);
};
connections.retain(|connection| connection.id != target.id);
if let Err(error) = self.backend.save_connections(&connections).await {
return tool_error("CONNECTION_SAVE_ERROR", error);
}
text(format!("Connection \"{}\" (id: {}) removed.", target.name, target.id))
}
#[tool(name = "dbx_open_table", description = "Open a table in DBX desktop app. Requires DBX to be running.")]
async fn open_table(&self, Parameters(request): Parameters<OpenTableRequest>) -> CallToolResult {
let connection = match self.resolve_connection(&request.selector).await {
Ok(connection) => connection,
Err(error) => return error,
};
let database = self.resolve_database(request.database, &connection);
match self
.backend
.bridge_request(
"/open-table",
json!({
"connection_id": connection.id,
"connection_name": connection.name,
"table": request.table,
"database": database,
"schema": request.schema,
}),
)
.await
{
Ok(()) => text(format!("Opened {} in DBX", request.table)),
Err(error) => tool_error("DBX_NOT_RUNNING", error),
}
}
#[tool(
name = "dbx_execute_and_show",
description = "Execute a SQL query in DBX desktop app UI and show results there. Requires DBX to be running."
)]
async fn execute_and_show(&self, Parameters(request): Parameters<ExecuteAndShowRequest>) -> CallToolResult {
let connection = match self.resolve_connection(&request.selector).await {
Ok(connection) => connection,
Err(error) => return error,
};
if connection.db_type == DatabaseType::Redis {
return tool_error("REDIS_COMMAND_REQUIRED", "Use dbx_execute_redis_command for Redis connections.");
}
let database = self.resolve_database(request.database, &connection);
let permissions = default_permissions();
if connection.db_type == DatabaseType::MongoDb {
if let Err(error) = validate_mongo_command(&connection, &database, &request.sql) {
return error;
}
} else {
let risk = match classify_sql_risk_for_database(&request.sql, connection.db_type) {
Ok(risk) => risk,
Err(error) => return tool_error("SQL_BLOCKED", error),
};
if risk != SqlRisk::ReadOnly && targets_production_database(&connection, &database, &request.sql) {
return tool_error(
"PRODUCTION_WRITE_BLOCKED",
"MCP cannot send writes against a production database to DBX.",
);
}
if risk == SqlRisk::Transaction || (risk == SqlRisk::Ddl && !permissions.allow_dangerous) {
return tool_error("SQL_BLOCKED", format!("{} statement is blocked for this session.", risk));
}
if risk == SqlRisk::Write && !permissions.allow_writes {
return tool_error("SQL_BLOCKED", "MCP SQL execution is read-only for this session.");
}
}
match self
.backend
.bridge_request(
"/execute-query",
json!({
"connection_id": connection.id,
"connection_name": connection.name,
"sql": request.sql,
"database": database,
"allow_writes": permissions.allow_writes,
"allow_dangerous": permissions.allow_dangerous,
}),
)
.await
{
Ok(()) => text("Query sent to DBX"),
Err(error) => tool_error("DBX_NOT_RUNNING", error),
}
}
}
impl DbxMcpServer {
async fn load_scoped_connections(&self) -> Result<Vec<dbx_core::models::connection::ConnectionConfig>, String> {
let connections = self.backend.load_connections().await?;
if !self.scope.enabled() {
return Ok(connections);
}
Ok(connections.into_iter().filter(|connection| self.scope.matches(connection)).collect())
}
fn resolve_database(
&self,
requested: Option<String>,
connection: &dbx_core::models::connection::ConnectionConfig,
) -> String {
requested.or_else(|| self.scope.database.clone()).or_else(|| connection.database.clone()).unwrap_or_default()
}
async fn resolve_connection(
&self,
selector: &ConnectionSelector,
) -> Result<dbx_core::models::connection::ConnectionConfig, CallToolResult> {
let connections =
self.backend.load_connections().await.map_err(|error| tool_error("CONNECTION_LOAD_ERROR", error))?;
if let Some(id) = selector.connection_id.as_deref().map(str::trim).filter(|id| !id.is_empty()) {
let connection = connections
.into_iter()
.find(|connection| connection.id == id)
.ok_or_else(|| tool_error("CONNECTION_NOT_FOUND", format!("Connection with id \"{id}\" not found.")))?;
if self.scope.enabled() && !self.scope.matches(&connection) {
return Err(tool_error(
"CONNECTION_OUT_OF_SCOPE",
format!("Connection \"{id}\" is outside this DBX AI session scope."),
));
}
return Ok(connection);
}
if self.scope.enabled() {
let connection = connections
.into_iter()
.find(|connection| self.scope.matches(connection))
.ok_or_else(|| tool_error("CONNECTION_NOT_FOUND", "Scoped DBX connection was not found."))?;
if let Some(name) = selector.connection_name.as_deref().map(str::trim).filter(|name| !name.is_empty()) {
if name != connection.name && name != connection.id {
return Err(tool_error(
"CONNECTION_OUT_OF_SCOPE",
format!("Connection \"{name}\" is outside this DBX AI session scope."),
));
}
}
return Ok(connection);
}
let Some(name) = selector.connection_name.as_deref().map(str::trim).filter(|name| !name.is_empty()) else {
return Err(tool_error("CONNECTION_NOT_FOUND", "Either connection_id or connection_name is required."));
};
let matching =
connections.into_iter().filter(|connection| connection.name.eq_ignore_ascii_case(name)).collect::<Vec<_>>();
match matching.as_slice() {
[] => Err(tool_error("CONNECTION_NOT_FOUND", format!("Connection \"{name}\" not found."))),
[connection] => Ok(connection.clone()),
_ => Err(tool_error("AMBIGUOUS_CONNECTION", ambiguous_connections(name, &matching))),
}
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for DbxMcpServer {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new("dbx", env!("CARGO_PKG_VERSION")))
.with_instructions("Use DBX connections to inspect schemas and query databases safely.")
}
}
fn text(value: impl Into<String>) -> CallToolResult {
CallToolResult::success(vec![ContentBlock::text(value)])
}
fn tool_error(code: &str, message: impl Into<String>) -> CallToolResult {
CallToolResult::error(vec![ContentBlock::text(format!("Error [{code}]: {}", message.into()))])
}
fn agent_result(result: dbx_core::agent_events::ToolResult) -> CallToolResult {
if result.is_error {
tool_error("DBX_TOOL_ERROR", result.content.trim_start_matches("Error: "))
} else {
text(result.content)
}
}
fn default_permissions() -> dbx_core::agent_tools::AgentSqlPermissions {
dbx_core::agent_tools::AgentSqlPermissions {
allow_writes: boolean_env("DBX_MCP_ALLOW_WRITES").unwrap_or(true),
allow_dangerous: boolean_env("DBX_MCP_ALLOW_DANGEROUS_SQL").unwrap_or(false),
}
}
fn validate_mongo_command(
connection: &dbx_core::models::connection::ConnectionConfig,
database: &str,
source: &str,
) -> Result<MongoCommand, CallToolResult> {
let command = mongo::parse(source).map_err(|error| {
tool_error(
"QUERY_ERROR",
format!(
"{error} Use MongoDB shell-style commands such as db.collection.find({{}}), db.collection.aggregate([]), or db.collection.countDocuments({{}})."
),
)
})?;
let permissions = default_permissions();
if command.is_mutating() && !permissions.allow_writes {
return Err(tool_error(
"SQL_BLOCKED",
"MCP MongoDB execution is read-only for this session. Set DBX_MCP_ALLOW_WRITES=1 to allow write commands.",
));
}
if command.has_empty_filter() && !permissions.allow_dangerous {
return Err(tool_error(
"SQL_BLOCKED",
"MongoDB update/delete commands must include a non-empty filter unless DBX_MCP_ALLOW_DANGEROUS_SQL=1 is set.",
));
}
if command.is_dangerous() && !permissions.allow_dangerous {
return Err(tool_error(
"SQL_BLOCKED",
"Dangerous MongoDB command is blocked. Set DBX_MCP_ALLOW_DANGEROUS_SQL=1 to allow it.",
));
}
if command.is_mutating() && is_production_database(connection, database) {
return Err(tool_error("PRODUCTION_WRITE_BLOCKED", "MCP cannot execute writes against a production database."));
}
Ok(command)
}
fn boolean_env(name: &str) -> Option<bool> {
match std::env::var(name).ok()?.trim().to_ascii_lowercase().as_str() {
"1" | "true" => Some(true),
"0" | "false" => Some(false),
_ => None,
}
}
fn non_empty_env(name: &str) -> Option<String> {
std::env::var(name).ok().map(|value| value.trim().to_string()).filter(|value| !value.is_empty())
}
fn default_port(db_type: &str) -> Option<u16> {
match db_type.trim().to_ascii_lowercase().as_str() {
"mysql" | "doris" | "starrocks" | "manticoresearch" => Some(3306),
"postgres" | "redshift" | "highgo" | "kingbase" | "opengauss" | "gaussdb" => Some(5432),
"redis" => Some(6379),
"mongodb" => Some(27017),
"rqlite" => Some(4001),
"kwdb" => Some(26257),
"cloudflare-d1" => Some(443),
"tdengine" => Some(6041),
"iotdb" => Some(6667),
"xugu" => Some(5138),
"sqlite" | "duckdb" | "access" => Some(0),
_ => None,
}
}
fn ambiguous_connections(name: &str, connections: &[dbx_core::models::connection::ConnectionConfig]) -> String {
let lines = connections
.iter()
.map(|connection| {
format!("- {}: {:?} @ {}:{}", connection.id, connection.db_type, connection.host, connection.port)
})
.collect::<Vec<_>>()
.join("\n");
format!("Multiple connections found with name \"{name}\". Please specify connection_id:\n{lines}")
}
fn format_connections(connections: &[ConnectionSummary]) -> String {
let mut output =
String::from("| ID | Name | Type | Host | Port | Database |\n| --- | --- | --- | --- | --- | --- |");
for connection in connections {
output.push_str(&format!(
"\n| {} | {} | {} | {} | {} | {} |",
escape_cell(&connection.id),
escape_cell(&connection.name),
escape_cell(&connection.db_type),
escape_cell(&connection.host),
connection.port,
escape_cell(&connection.database),
));
}
output
}
fn format_columns(columns: &[dbx_core::db::ColumnInfo]) -> String {
let rows = columns
.iter()
.map(|column| {
vec![
if column.is_primary_key { format!("{} (PK)", column.name) } else { column.name.clone() },
column.data_type.clone(),
if column.is_nullable { "YES".to_string() } else { "NO".to_string() },
column.column_default.clone().unwrap_or_default(),
column.comment.clone().unwrap_or_default(),
]
})
.collect::<Vec<_>>();
markdown_table(&["Column", "Type", "Nullable", "Default", "Comment"], &rows)
}
fn markdown_table(headers: &[&str], rows: &[Vec<String>]) -> String {
let mut output = format!("| {} |\n| {} |", headers.join(" | "), vec!["---"; headers.len()].join(" | "));
for row in rows {
output
.push_str(&format!("\n| {} |", row.iter().map(|value| escape_cell(value)).collect::<Vec<_>>().join(" | ")));
}
output
}
fn escape_cell(value: &str) -> String {
value.replace('|', "\\|").replace('\n', " ")
}
fn redis_database(connection: &dbx_core::models::connection::ConnectionConfig) -> Option<u32> {
connection.database.as_deref().and_then(parse_redis_database)
}
fn parse_redis_database(value: &str) -> Option<u32> {
value.trim().parse().ok()
}
fn format_redis_result(result: &RedisCommandResult) -> String {
let value =
result.value.as_str().map(ToOwned::to_owned).unwrap_or_else(|| {
serde_json::to_string_pretty(&result.value).unwrap_or_else(|_| result.value.to_string())
});
let safety = serde_json::to_value(&result.safety)
.ok()
.and_then(|value| value.as_str().map(ToOwned::to_owned))
.unwrap_or_else(|| format!("{:?}", result.safety).to_ascii_lowercase());
format!("Command: {}\nSafety: {}\n\n{}", result.command, safety, value)
}
fn format_schema_context(
connection: &str,
database: &str,
schema: &str,
tables: &[(dbx_core::db::TableInfo, Vec<dbx_core::db::ColumnInfo>)],
truncated: bool,
) -> String {
let mut output = format!("Connection: {connection}");
if !database.is_empty() {
output.push_str(&format!("\nDatabase: {database}"));
}
if !schema.is_empty() {
output.push_str(&format!("\nSchema: {schema}"));
}
for (table, columns) in tables {
output.push_str(&format!("\n\n## {}\nType: {}", table.name, table.table_type));
for column in columns {
output.push_str(&format!(
"\n- {} {} {}{}{}",
column.name,
column.data_type,
if column.is_nullable { "NULL" } else { "NOT NULL" },
if column.is_primary_key { " PK" } else { "" },
column.comment.as_ref().map(|comment| format!(" -- {comment}")).unwrap_or_default(),
));
}
}
if truncated {
output.push_str("\n\nNote: table list was truncated; request specific table names for more context.");
}
output
}
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use dbx_core::models::connection::ConnectionConfig;
struct FakeBackend {
connections: Vec<ConnectionConfig>,
}
#[async_trait]
impl DbxBackend for FakeBackend {
async fn load_connections(&self) -> Result<Vec<ConnectionConfig>, String> {
Ok(self.connections.clone())
}
async fn execute_agent_tool(
&self,
_connection: &ConnectionConfig,
_database: &str,
tool_name: &str,
_arguments: serde_json::Value,
_permissions: dbx_core::agent_tools::AgentSqlPermissions,
) -> dbx_core::agent_events::ToolResult {
dbx_core::agent_events::ToolResult {
tool_call_id: "test".to_string(),
tool_name: tool_name.to_string(),
content: "ok".to_string(),
is_error: false,
explain_data: None,
}
}
async fn save_connections(&self, _connections: &[ConnectionConfig]) -> Result<(), String> {
Ok(())
}
}
#[test]
fn connection_table_escapes_markdown_cells() {
let output = format_connections(&[ConnectionSummary {
id: "id|1".to_string(),
name: "local\npg".to_string(),
db_type: "postgres".to_string(),
host: "127.0.0.1".to_string(),
port: 5432,
database: "app".to_string(),
}]);
assert!(output.contains("id\\|1"));
assert!(output.contains("local pg"));
}
#[test]
fn server_registers_list_connections_tool() {
let server = DbxMcpServer::with_runtime_options(
Arc::new(FakeBackend { connections: Vec::new() }),
McpScope::default(),
false,
);
let tools = server.tool_router.list_all();
let names = tools.iter().map(|tool| tool.name.as_ref()).collect::<Vec<_>>();
assert_eq!(tools.len(), 10);
assert!(names.contains(&"dbx_list_connections"));
assert!(names.contains(&"dbx_list_tables"));
assert!(names.contains(&"dbx_describe_table"));
assert!(names.contains(&"dbx_execute_query"));
assert!(names.contains(&"dbx_add_connection"));
assert!(names.contains(&"dbx_remove_connection"));
assert!(names.contains(&"dbx_execute_redis_command"));
assert!(names.contains(&"dbx_get_schema_context"));
assert!(names.contains(&"dbx_open_table"));
assert!(names.contains(&"dbx_execute_and_show"));
}
#[test]
fn scoped_server_hides_mutating_and_desktop_tools() {
let server = DbxMcpServer::with_runtime_options(
Arc::new(FakeBackend { connections: Vec::new() }),
McpScope { connection_id: Some("scoped".to_string()), ..Default::default() },
false,
);
let names = server.tool_router.list_all().into_iter().map(|tool| tool.name).collect::<Vec<_>>();
assert_eq!(names.len(), 6);
assert!(!names.iter().any(|name| name == "dbx_add_connection"));
assert!(!names.iter().any(|name| name == "dbx_remove_connection"));
assert!(!names.iter().any(|name| name == "dbx_open_table"));
assert!(!names.iter().any(|name| name == "dbx_execute_and_show"));
}
}

View File

@ -0,0 +1,111 @@
use std::sync::Arc;
use dbx_core::{models::connection::ConnectionConfig, storage::Storage};
use dbx_mcp::{DbxMcpServer, LocalBackend, McpScope};
use rmcp::{model::CallToolRequestParams, ServiceExt};
use serde_json::{json, Map, Value};
use tempfile::tempdir;
#[tokio::test]
async fn local_backend_reads_dbx_storage_without_desktop_process() {
let directory = tempdir().expect("temporary data directory");
let db_path = directory.path().join("dbx.db");
let storage = Storage::open(&db_path).await.expect("open storage");
let connection: ConnectionConfig = serde_json::from_value(json!({
"id": "local-sqlite",
"name": "offline-sqlite",
"db_type": "sqlite",
"host": "",
"port": 0,
"username": "",
"password": "",
"database": directory.path().join("data.sqlite").to_string_lossy(),
"ssl": false
}))
.expect("minimal connection config");
storage.save_connections(&[connection]).await.expect("save connection");
let backend = Arc::new(LocalBackend::open(&db_path).await.expect("open local backend"));
let server = DbxMcpServer::with_runtime_options(backend, McpScope::default(), false);
let (server_transport, client_transport) = tokio::io::duplex(16 * 1024);
let server_task = tokio::spawn(async move { server.serve(server_transport).await });
let client = ().serve(client_transport).await.expect("initialize client");
let result = client
.peer()
.call_tool(CallToolRequestParams::new("dbx_list_connections"))
.await
.expect("list local connections");
let text = result.content[0].as_text().expect("text response");
assert!(text.text.contains("offline-sqlite"));
assert!(text.text.contains("local-sqlite"));
client.cancel().await.expect("close client");
server_task.abort();
}
#[tokio::test]
#[ignore = "requires DBX_MCP_TEST_MONGO_HOST and DBX_MCP_TEST_MONGO_PASSWORD"]
async fn executes_mongo_shell_commands_without_desktop_process() {
let host = std::env::var("DBX_MCP_TEST_MONGO_HOST").expect("MongoDB host");
let port = std::env::var("DBX_MCP_TEST_MONGO_PORT")
.unwrap_or_else(|_| "27017".to_string())
.parse::<u16>()
.expect("MongoDB port");
let password = std::env::var("DBX_MCP_TEST_MONGO_PASSWORD").expect("MongoDB password");
let directory = tempdir().expect("temporary data directory");
let db_path = directory.path().join("dbx.db");
let storage = Storage::open(&db_path).await.expect("open storage");
let connection: ConnectionConfig = serde_json::from_value(json!({
"id": "mongo-e2e",
"name": "mongo-e2e",
"db_type": "mongodb",
"host": host,
"port": port,
"username": "root",
"password": password,
"database": "dbx_mcp_test",
"url_params": "authSource=admin",
"ssl": false
}))
.expect("MongoDB connection config");
storage.save_connections(&[connection]).await.expect("save connection");
let backend = Arc::new(LocalBackend::open(&db_path).await.expect("open local backend"));
let server = DbxMcpServer::with_runtime_options(backend, McpScope::default(), false);
let (server_transport, client_transport) = tokio::io::duplex(32 * 1024);
let server_task = tokio::spawn(async move { server.serve(server_transport).await });
let client = ().serve(client_transport).await.expect("initialize client");
let original_writes = std::env::var_os("DBX_MCP_ALLOW_WRITES");
std::env::set_var("DBX_MCP_ALLOW_WRITES", "1");
call_query(&client, "db.items.deleteOne({_id: 'rust-mcp-e2e'})").await;
call_query(&client, "db.items.insertOne({_id: 'rust-mcp-e2e', name: 'Ada'})").await;
let result = call_query(&client, "db.items.find({_id: 'rust-mcp-e2e'}).limit(1)").await;
assert!(result.contains("Ada"), "unexpected MongoDB result: {result}");
call_query(&client, "db.items.deleteOne({_id: 'rust-mcp-e2e'})").await;
match original_writes {
Some(value) => std::env::set_var("DBX_MCP_ALLOW_WRITES", value),
None => std::env::remove_var("DBX_MCP_ALLOW_WRITES"),
}
client.cancel().await.expect("close client");
server_task.abort();
}
async fn call_query(client: &rmcp::service::RunningService<rmcp::RoleClient, ()>, sql: &str) -> String {
let arguments = json!({
"connection_id": "mongo-e2e",
"database": "dbx_mcp_test",
"sql": sql,
})
.as_object()
.cloned()
.unwrap_or_else(Map::<String, Value>::new);
let result = client
.peer()
.call_tool(CallToolRequestParams::new("dbx_execute_query").with_arguments(arguments))
.await
.expect("execute MongoDB command");
let text = result.content[0].as_text().expect("text result").text.clone();
assert_ne!(result.is_error, Some(true), "MongoDB command failed: {text}");
text
}

View File

@ -0,0 +1,59 @@
use std::sync::Arc;
use async_trait::async_trait;
use dbx_core::{agent_events::ToolResult, agent_tools::AgentSqlPermissions, models::connection::ConnectionConfig};
use dbx_mcp::{DbxBackend, DbxMcpServer, McpScope};
use rmcp::{model::CallToolRequestParams, ServiceExt};
use serde_json::Value;
struct EmptyBackend;
#[async_trait]
impl DbxBackend for EmptyBackend {
async fn load_connections(&self) -> Result<Vec<ConnectionConfig>, String> {
Ok(Vec::new())
}
async fn execute_agent_tool(
&self,
_connection: &ConnectionConfig,
_database: &str,
tool_name: &str,
_arguments: Value,
_permissions: AgentSqlPermissions,
) -> ToolResult {
ToolResult {
tool_call_id: "protocol-test".to_string(),
tool_name: tool_name.to_string(),
content: "ok".to_string(),
is_error: false,
explain_data: None,
}
}
async fn save_connections(&self, _connections: &[ConnectionConfig]) -> Result<(), String> {
Ok(())
}
}
#[tokio::test]
async fn initializes_lists_tools_and_calls_a_tool() {
let (server_transport, client_transport) = tokio::io::duplex(16 * 1024);
let server = DbxMcpServer::with_runtime_options(Arc::new(EmptyBackend), McpScope::default(), false);
let server_task = tokio::spawn(async move { server.serve(server_transport).await });
let client = ().serve(client_transport).await.expect("initialize MCP client");
let tools = client.peer().list_tools(None).await.expect("list tools");
let names = tools.tools.iter().map(|tool| tool.name.as_ref()).collect::<Vec<_>>();
assert_eq!(names.len(), 10);
assert!(names.contains(&"dbx_list_connections"));
assert!(names.contains(&"dbx_execute_redis_command"));
assert!(names.contains(&"dbx_execute_and_show"));
let result = client.peer().call_tool(CallToolRequestParams::new("dbx_list_connections")).await.expect("call tool");
let response = result.content[0].as_text().expect("text response");
assert_eq!(response.text, "No connections configured in DBX.");
client.cancel().await.expect("close MCP client");
server_task.abort();
}

View File

@ -1,6 +1,6 @@
{
"name": "@dbx-app/cli",
"version": "0.4.32",
"version": "0.4.33",
"description": "Command line interface for DBX database connections, schema, and safe queries",
"keywords": [
"ai-agent",

View File

@ -0,0 +1,3 @@
# @dbx-app/mcp-darwin-arm64
Platform-specific Rust binary used by `@dbx-app/mcp-server`. Install the main package instead of depending on this package directly.

Binary file not shown.

View File

@ -0,0 +1,23 @@
{
"name": "@dbx-app/mcp-darwin-arm64",
"version": "0.4.33",
"description": "Precompiled DBX MCP Rust binary for darwin arm64",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/t8y2/dbx",
"directory": "packages/mcp-darwin-arm64"
},
"os": [
"darwin"
],
"cpu": [
"arm64"
],
"bin": {
"dbx-mcp-native": "bin/dbx-mcp"
},
"files": [
"bin"
]
}

View File

@ -0,0 +1,3 @@
# @dbx-app/mcp-darwin-x64
Platform-specific Rust binary used by `@dbx-app/mcp-server`. Install the main package instead of depending on this package directly.

View File

@ -0,0 +1,23 @@
{
"name": "@dbx-app/mcp-darwin-x64",
"version": "0.4.33",
"description": "Precompiled DBX MCP Rust binary for darwin x64",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/t8y2/dbx",
"directory": "packages/mcp-darwin-x64"
},
"os": [
"darwin"
],
"cpu": [
"x64"
],
"bin": {
"dbx-mcp-native": "bin/dbx-mcp"
},
"files": [
"bin"
]
}

View File

@ -0,0 +1,3 @@
# @dbx-app/mcp-linux-arm64-gnu
Platform-specific Rust binary used by `@dbx-app/mcp-server`. Install the main package instead of depending on this package directly.

View File

@ -0,0 +1,26 @@
{
"name": "@dbx-app/mcp-linux-arm64-gnu",
"version": "0.4.33",
"description": "Precompiled DBX MCP Rust binary for linux arm64",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/t8y2/dbx",
"directory": "packages/mcp-linux-arm64-gnu"
},
"os": [
"linux"
],
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"bin": {
"dbx-mcp-native": "bin/dbx-mcp"
},
"files": [
"bin"
]
}

View File

@ -0,0 +1,3 @@
# @dbx-app/mcp-linux-x64-gnu
Platform-specific Rust binary used by `@dbx-app/mcp-server`. Install the main package instead of depending on this package directly.

View File

@ -0,0 +1,26 @@
{
"name": "@dbx-app/mcp-linux-x64-gnu",
"version": "0.4.33",
"description": "Precompiled DBX MCP Rust binary for linux x64",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/t8y2/dbx",
"directory": "packages/mcp-linux-x64-gnu"
},
"os": [
"linux"
],
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"bin": {
"dbx-mcp-native": "bin/dbx-mcp"
},
"files": [
"bin"
]
}

View File

@ -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)
- **9 tools** — List/add/remove connections, list tables, describe table, get schema context, execute SQL, execute Redis commands, open table in DBX UI
- **10 tools** — List/add/remove connections, list tables, describe table, get schema context, execute SQL, execute Redis commands, open tables, and execute-and-show results
- **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
@ -27,6 +27,24 @@ Or run directly:
npx @dbx-app/mcp-server
```
The npm package installs a small Node.js launcher plus a precompiled Rust binary for the current platform. It does not install `better-sqlite3`, compile native modules, or require Rust/Cargo locally. Do not use `--no-optional`, because the platform binary is delivered through npm optional dependencies.
### Offline / native binary
For an offline machine, download the `dbx-mcp` binary matching the operating system and CPU from the DBX release assets, then configure the MCP client to run that file directly:
```json
{
"mcpServers": {
"dbx": {
"command": "/path/to/dbx-mcp"
}
}
}
```
The native binary works without Node.js and without opening the DBX desktop app for supported direct database connections. Set `DBX_DATA_DIR` when the DBX database is stored outside the default location. `dbx_open_table` and other desktop bridge features still require a running DBX app.
### 2. Configure Claude Code
Add to your project's `.mcp.json`:
@ -173,7 +191,8 @@ PostgreSQL, MySQL, SQLite, Doris, StarRocks, and Redshift queries run directly f
## Requirements
- [DBX](https://github.com/t8y2/dbx) installed with at least one connection configured
- Node.js 22.13.0 或更高版本
- Node.js 18.18.0 or newer (only used by the npm launcher)
- Rust, Cargo, Python, and native build tools are not required
## License
@ -272,6 +291,7 @@ dbx query local "select 1" --json
| `dbx_execute_query` | 执行 SQL 查询(最多返回 100 行) |
| `dbx_execute_redis_command` | 在 Redis 连接上执行 Redis 命令 |
| `dbx_open_table` | 在 DBX 桌面端打开指定表 |
| `dbx_execute_and_show` | 执行查询并在 DBX 中展示结果 |
### SQL 安全
@ -330,4 +350,6 @@ PostgreSQL、MySQL、SQLite、Doris、StarRocks、Redshift 查询可由 MCP Serv
### 系统要求
- 已安装 [DBX](https://github.com/t8y2/dbx) 并配置了至少一个数据库连接
- Node.js 22.13.0 or newer
- Node.js 18.18.0 或更高版本(仅用于 npm 启动器)
- 不需要安装 Rust、Cargo、Python 或本地编译工具
- 离线直接运行原生二进制时不需要安装 Node.js

View File

@ -0,0 +1,67 @@
#!/usr/bin/env node
import { spawn } from "node:child_process";
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
const require = createRequire(import.meta.url);
const platformPackages = {
"darwin-arm64": ["@dbx-app/mcp-darwin-arm64", "dbx-mcp"],
"darwin-x64": ["@dbx-app/mcp-darwin-x64", "dbx-mcp"],
"linux-arm64": ["@dbx-app/mcp-linux-arm64-gnu", "dbx-mcp"],
"linux-x64": ["@dbx-app/mcp-linux-x64-gnu", "dbx-mcp"],
"win32-arm64": ["@dbx-app/mcp-win32-arm64", "dbx-mcp.exe"],
"win32-x64": ["@dbx-app/mcp-win32-x64", "dbx-mcp.exe"],
};
function resolveBinary() {
if (process.env.DBX_MCP_BINARY) {
return process.env.DBX_MCP_BINARY;
}
const platform = `${process.platform}-${process.arch}`;
const target = platformPackages[platform];
if (!target) {
throw new Error(`DBX MCP does not provide a Rust binary for ${platform}.`);
}
const [packageName, binaryName] = target;
let manifest;
try {
manifest = require.resolve(`${packageName}/package.json`);
} catch {
throw new Error(
`The optional package ${packageName} was not installed. Reinstall @dbx-app/mcp-server without --no-optional.`,
);
}
const binary = join(dirname(manifest), "bin", binaryName);
if (!existsSync(binary)) {
throw new Error(`The DBX MCP binary is missing from ${packageName}.`);
}
return binary;
}
try {
if (process.argv[2] === "--verify-platform") {
const platform = `${process.platform}-${process.arch}`;
if (!platformPackages[platform]) {
throw new Error(`DBX MCP does not provide a Rust binary for ${platform}.`);
}
process.exit(0);
}
const binary = resolveBinary();
const child = spawn(binary, process.argv.slice(2), { stdio: "inherit", env: process.env });
for (const signal of ["SIGINT", "SIGTERM"]) {
process.on(signal, () => child.kill(signal));
}
child.on("error", (error) => {
console.error(`Failed to start DBX MCP: ${error.message}`);
process.exit(1);
});
child.on("exit", (code, signal) => {
if (signal) process.kill(process.pid, signal);
else process.exit(code ?? 1);
});
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
process.exit(1);
}

View File

@ -1,6 +1,6 @@
{
"name": "@dbx-app/mcp-server",
"version": "0.4.32",
"version": "0.4.33",
"description": "MCP server for DBX — query databases from Claude Code, Cursor, and other AI agents",
"keywords": [
"ai-agent",
@ -19,32 +19,40 @@
"directory": "packages/mcp-server"
},
"bin": {
"dbx-mcp-server": "dist/index.js",
"mcp-server": "dist/index.js"
"dbx-mcp-server": "bin/dbx-mcp-server.js",
"mcp-server": "bin/dbx-mcp-server.js"
},
"files": [
"dist",
"bin",
"server.json"
],
"type": "module",
"scripts": {
"start": "tsx src/index.ts",
"test": "pnpm --filter @dbx-app/mcp-server build && vitest run --config vitest.config.ts",
"build": "pnpm --filter @dbx-app/node-core build && tsc",
"prepublishOnly": "tsc"
"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",
"prepublishOnly": "node bin/dbx-mcp-server.js --verify-platform"
},
"dependencies": {
"@dbx-app/node-core": "workspace:^",
"@modelcontextprotocol/sdk": "^1.12.1",
"zod": "^3.25.20"
"optionalDependencies": {
"@dbx-app/mcp-darwin-arm64": "0.4.33",
"@dbx-app/mcp-darwin-x64": "0.4.33",
"@dbx-app/mcp-linux-arm64-gnu": "0.4.33",
"@dbx-app/mcp-linux-x64-gnu": "0.4.33",
"@dbx-app/mcp-win32-arm64": "0.4.33",
"@dbx-app/mcp-win32-x64": "0.4.33"
},
"devDependencies": {
"@dbx-app/node-core": "workspace:^",
"@modelcontextprotocol/sdk": "^1.12.1",
"@types/node": "^22.15.21",
"tsx": "^4.19.4",
"typescript": "^5.8.3"
"typescript": "^5.8.3",
"vitest": "^4.1.8",
"zod": "^3.25.20"
},
"engines": {
"node": ">=22.13.0"
"node": ">=18.18.0"
},
"mcpName": "io.github.t8y2/dbx"
}

View File

@ -6,12 +6,12 @@
"url": "https://github.com/t8y2/dbx",
"source": "github"
},
"version": "0.4.32",
"version": "0.4.33",
"packages": [
{
"registryType": "npm",
"identifier": "@dbx-app/mcp-server",
"version": "0.4.32",
"version": "0.4.33",
"transport": {
"type": "stdio"
}

View File

@ -2,12 +2,13 @@ import assert from "node:assert/strict";
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { mkdtemp, rm, symlink } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { test } from "vitest";
const packageDir = fileURLToPath(new URL("..", import.meta.url));
const mcpBin = fileURLToPath(new URL("../dist/index.js", import.meta.url));
const mcpBin = fileURLToPath(new URL("../bin/dbx-mcp-server.js", import.meta.url));
const rustBinary = join(process.env.CARGO_TARGET_DIR || resolve(packageDir, "../..", "target"), "debug", process.platform === "win32" ? "dbx-mcp.exe" : "dbx-mcp");
type InitializeResponse = {
id: number;
@ -24,7 +25,7 @@ test("responds to initialize when invoked through an npm-style symlink", async (
try {
child = spawn(process.execPath, [bin.path], {
cwd: packageDir,
env: { ...process.env },
env: { ...process.env, DBX_MCP_BINARY: rustBinary },
});
const responsePromise = readJsonRpcResponse(child, 5000);

View File

@ -0,0 +1,3 @@
# @dbx-app/mcp-win32-arm64
Platform-specific Rust binary used by `@dbx-app/mcp-server`. Install the main package instead of depending on this package directly.

View File

@ -0,0 +1,23 @@
{
"name": "@dbx-app/mcp-win32-arm64",
"version": "0.4.33",
"description": "Precompiled DBX MCP Rust binary for win32 arm64",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/t8y2/dbx",
"directory": "packages/mcp-win32-arm64"
},
"os": [
"win32"
],
"cpu": [
"arm64"
],
"bin": {
"dbx-mcp-native": "bin/dbx-mcp.exe"
},
"files": [
"bin"
]
}

View File

@ -0,0 +1,3 @@
# @dbx-app/mcp-win32-x64
Platform-specific Rust binary used by `@dbx-app/mcp-server`. Install the main package instead of depending on this package directly.

View File

@ -0,0 +1,23 @@
{
"name": "@dbx-app/mcp-win32-x64",
"version": "0.4.33",
"description": "Precompiled DBX MCP Rust binary for win32 x64",
"license": "Apache-2.0",
"repository": {
"type": "git",
"url": "https://github.com/t8y2/dbx",
"directory": "packages/mcp-win32-x64"
},
"os": [
"win32"
],
"cpu": [
"x64"
],
"bin": {
"dbx-mcp-native": "bin/dbx-mcp.exe"
},
"files": [
"bin"
]
}

View File

@ -1,6 +1,6 @@
{
"name": "@dbx-app/mongo-shell",
"version": "0.4.32",
"version": "0.4.33",
"description": "Pure MongoDB shell JSON/argument parsing shared by desktop and node-core",
"license": "Apache-2.0",
"type": "module",

View File

@ -1,6 +1,6 @@
{
"name": "@dbx-app/node-core",
"version": "0.4.32",
"version": "0.4.33",
"description": "Shared Node.js database and DBX connection utilities for DBX CLI and MCP server",
"license": "Apache-2.0",
"files": [

View File

@ -248,18 +248,41 @@ importers:
specifier: ^5.8.3
version: 5.9.3
packages/mcp-darwin-arm64: {}
packages/mcp-darwin-x64: {}
packages/mcp-linux-arm64-gnu: {}
packages/mcp-linux-x64-gnu: {}
packages/mcp-server:
dependencies:
optionalDependencies:
'@dbx-app/mcp-darwin-arm64':
specifier: 0.4.33
version: link:../mcp-darwin-arm64
'@dbx-app/mcp-darwin-x64':
specifier: 0.4.33
version: link:../mcp-darwin-x64
'@dbx-app/mcp-linux-arm64-gnu':
specifier: 0.4.33
version: link:../mcp-linux-arm64-gnu
'@dbx-app/mcp-linux-x64-gnu':
specifier: 0.4.33
version: link:../mcp-linux-x64-gnu
'@dbx-app/mcp-win32-arm64':
specifier: 0.4.33
version: link:../mcp-win32-arm64
'@dbx-app/mcp-win32-x64':
specifier: 0.4.33
version: link:../mcp-win32-x64
devDependencies:
'@dbx-app/node-core':
specifier: workspace:^
version: link:../node-core
'@modelcontextprotocol/sdk':
specifier: ^1.12.1
version: 1.29.0(zod@3.25.76)
zod:
specifier: ^3.25.20
version: 3.25.76
devDependencies:
'@types/node':
specifier: ^22.15.21
version: 22.19.19
@ -269,6 +292,16 @@ importers:
typescript:
specifier: ^5.8.3
version: 5.9.3
vitest:
specifier: ^4.1.8
version: 4.1.8(@types/node@22.19.19)(happy-dom@20.10.6)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.21.0)(yaml@2.8.4))
zod:
specifier: ^3.25.20
version: 3.25.76
packages/mcp-win32-arm64: {}
packages/mcp-win32-x64: {}
packages/mongo-shell:
devDependencies:
@ -1059,56 +1092,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.53.0':
resolution: {integrity: sha512-I6bhOTroqc3ThrwZ89l2k3ivKuELhdPLbAcJhRNyjWvlgwb0vjRgEnVL1XLx5Jud04/ypNRZBykAWrSk6l/D+g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.53.0':
resolution: {integrity: sha512-w0p3JzB/PkkQjXALMJMqP9YfP3yq4w6zGsu5kezQmUnxRkN3b/Theg2l/nDgBsOcczxS3gL6Gam5XNAVrO6QJQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.53.0':
resolution: {integrity: sha512-mzBhF6k1Yq1K/dqDmVe/AAafnlJfEpx7yfUiksyeWXJk5iSzZqBSxcsa02zIytYgQFRZ7h6WPZfwHg/DoOE1Kw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.53.0':
resolution: {integrity: sha512-AlFCpnRQhogQFzZXWbO6xB6/Udy745L+eQNmDPGg7G/OeWsYmJc4jZYfUN5pQg0reOPWSED2mOQqKZOJM1U8cA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.53.0':
resolution: {integrity: sha512-XD4ulY4f1DWbuuZXAqxhVn+gdPmrhnmojWtFN78ctVoupmS845fGhsUrk1HZXKQI+iymbaiz9vAjPsghHNQ7Ag==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.53.0':
resolution: {integrity: sha512-xg8KWX0QnxmYWRe60CgHYWXI0ZOtBbqTsXvWiWrcl2XUHJ3fht2QerOk2iWvylzX3zNT2GpvBRxGoR4d3sxPRQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.53.0':
resolution: {integrity: sha512-MWExpYBGvl+pIvVB/gj/CcWlN2al8AizT7rUbtaYaWNoQkhWARM6W3qpgoCr72CYSN9PborzPmM5MIRe2BrNdA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.53.0':
resolution: {integrity: sha512-u4sajgO4nxgmJIgc/y2AqPhkdbOkQH8WugXpA1+pW0ESQhvGZ1oGq61Q4xMbJHJU1hFgtO18QNrcFYDPYH0gwQ==}
@ -1181,56 +1206,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.68.0':
resolution: {integrity: sha512-qVKtCZNic+OoNnOr/hCQAu22HSQzflI7Fsq/Blzkw02SnLuv163k3kfmrVpZjSBlUHgsRKj6WgQiw30d3SX02Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.68.0':
resolution: {integrity: sha512-zExyZ8ZOUuAyQ0y9jpTcyjKUz62YY9JhKPyVxzvjTpXzZ3ujdqiVwfPWDdnA1SsIOrxdtxHn7KErDHLWskFjXg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.68.0':
resolution: {integrity: sha512-6C4MPuwewyDavA7sxM14wzgRi5GGL68HPIxRCdVyS75U4MDbpFVYzKO9WNR6KLKTMPq2pcz3THwo1sK2uiqngw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.68.0':
resolution: {integrity: sha512-bnZooVeHAcvA+dH0EDLgx+7HY/DRi6e0hFszg3P+OBatuUjV6EvfIyNIzWOusmqAVh4L6r21GGTZtiKE4iqM4Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.68.0':
resolution: {integrity: sha512-dIqnZnJSmHCMOUpUcWQOiV14o3DDPVx1DSsMaSzvdhNjC1tB1iEPZbdiMSCIEYbkgbsYznHXWqFdKL8WUB3F8g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.68.0':
resolution: {integrity: sha512-zc9lEnfV/HreDTY6gdMlZe+irkwHSxQ4/B1pS9GyK7RVaA5LxhoZY/w6/o2vIwLLEYiXQ5ujGxOM1ZazeFAAIA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.68.0':
resolution: {integrity: sha512-Dl5QEX0TCo/40Cdh1o1JdPS//+YiWqjC+Hrrya5OQmStZZr4svAFtdlqcpCrU9yq2Mo3vRVyO9B3h0dzD8s36Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.68.0':
resolution: {integrity: sha512-/qy6dOvi4S3/LeXq0l5BT5pRKPYA7oj3uKwJOAZOr5HRLL+HK6jdBynvWuXIA2wwfE01RzNYmbBdM7vwYx00sA==}
@ -1300,42 +1317,36 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.3':
resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.0.3':
resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.0.3':
resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.0.3':
resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.3':
resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.3':
resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==}
@ -1438,28 +1449,24 @@ packages:
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-arm64-musl@4.3.0':
resolution: {integrity: sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==}
engines: {node: '>= 20'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-linux-x64-gnu@4.3.0':
resolution: {integrity: sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tailwindcss/oxide-linux-x64-musl@4.3.0':
resolution: {integrity: sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==}
engines: {node: '>= 20'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tailwindcss/oxide-wasm32-wasi@4.3.0':
resolution: {integrity: sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==}
@ -1528,35 +1535,30 @@ packages:
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-arm64-musl@2.11.2':
resolution: {integrity: sha512-X1rm0BERqAAggtYTESSgXrS3sz4Sb/OiPiz54UqISlXW+GkR3vNIGnsy/lejNmoXGVqri3Q53BCfQiclOIyRPw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-linux-riscv64-gnu@2.11.2':
resolution: {integrity: sha512-usbMLJbT3KtkOrBMDVeGYNM35aTHXx38SJSzTMSqqjeUIOQ+iVPjb2yAGNAE+KqmBbAx4FOFIyMeKXx2M/JKGQ==}
engines: {node: '>= 10'}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-gnu@2.11.2':
resolution: {integrity: sha512-Ru4gwJKPG0ctVGchRGpRup4Y4lW2SSfFnrbQcyHhCliKy4g8Qz97TrUgCur4CbWyAgKxvGh3SjrkA0LDYzDGiw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@tauri-apps/cli-linux-x64-musl@2.11.2':
resolution: {integrity: sha512-eUm7T6clN1MMmNSRQ9gaWsQdyehQx2Gmn5hht/QUlqZQI/qcP2OJK5dnaxqwFzCr2HdsEo9ydxaqcS1oJzMvUw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@tauri-apps/cli-win32-arm64-msvc@2.11.2':
resolution: {integrity: sha512-HeeZW80jU+gVTOEX4X/hC6NVSAdDVXajwP5fxIZ/3z9WvUC7qrudX2GMTilYq6Dg0e0sk0XgsAJD1hZ5wPBXUA==}
@ -2895,28 +2897,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@ -5418,6 +5416,14 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.8(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.21.0)(yaml@2.8.4))':
dependencies:
'@vitest/spy': 4.1.8
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.21.0)(yaml@2.8.4)
'@vitest/mocker@4.1.8(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4))':
dependencies:
'@vitest/spy': 4.1.8
@ -7777,6 +7783,22 @@ snapshots:
'@types/unist': 3.0.3
vfile-message: 4.0.3
vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.21.0)(yaml@2.8.4):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
postcss: 8.5.15
rolldown: 1.0.3
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 22.19.19
esbuild: 0.28.0
fsevents: 2.3.3
jiti: 2.7.0
stylus: 0.57.0
tsx: 4.21.0
yaml: 2.8.4
vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4):
dependencies:
lightningcss: 1.32.0
@ -7793,6 +7815,34 @@ snapshots:
tsx: 4.22.4
yaml: 2.8.4
vitest@4.1.8(@types/node@22.19.19)(happy-dom@20.10.6)(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.21.0)(yaml@2.8.4)):
dependencies:
'@vitest/expect': 4.1.8
'@vitest/mocker': 4.1.8(vite@8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.21.0)(yaml@2.8.4))
'@vitest/pretty-format': 4.1.8
'@vitest/runner': 4.1.8
'@vitest/snapshot': 4.1.8
'@vitest/spy': 4.1.8
'@vitest/utils': 4.1.8
es-module-lexer: 2.1.0
expect-type: 1.3.0
magic-string: 0.30.21
obug: 2.1.2
pathe: 2.0.3
picomatch: 4.0.4
std-env: 4.1.0
tinybench: 2.9.0
tinyexec: 1.1.1
tinyglobby: 0.2.17
tinyrainbow: 3.1.0
vite: 8.0.16(@types/node@22.19.19)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.21.0)(yaml@2.8.4)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 22.19.19
happy-dom: 20.10.6
transitivePeerDependencies:
- msw
vitest@4.1.8(@types/node@25.9.1)(happy-dom@20.10.6)(vite@8.0.16(@types/node@25.9.1)(esbuild@0.28.0)(jiti@2.7.0)(stylus@0.57.0)(tsx@4.22.4)(yaml@2.8.4)):
dependencies:
'@vitest/expect': 4.1.8

View File

@ -22,10 +22,21 @@ const PACKAGE_RELEASE_PATHS = [
"packages/cli/package.json",
"packages/cli/tsconfig.json",
"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/",
"packages/mcp-linux-x64-gnu/",
"packages/mcp-win32-arm64/",
"packages/mcp-win32-x64/",
"crates/dbx-mcp/",
"Cargo.toml",
"Cargo.lock",
".github/workflows/mcp-release.yml",
];
const AGENT_RELEASE_PATHS = [
"agents/build.gradle",
@ -262,6 +273,12 @@ function getLatestPackageVersion() {
"packages/node-core/package.json",
"packages/cli/package.json",
"packages/mcp-server/package.json",
"packages/mcp-darwin-arm64/package.json",
"packages/mcp-darwin-x64/package.json",
"packages/mcp-linux-arm64-gnu/package.json",
"packages/mcp-linux-x64-gnu/package.json",
"packages/mcp-win32-arm64/package.json",
"packages/mcp-win32-x64/package.json",
].map((path) => JSON.parse(readFileSync(path, "utf8")).version);
const uniqueVersions = [...new Set(packageVersions)];