diff --git a/.github/workflows/mcp-release.yml b/.github/workflows/mcp-release.yml index e594c53f3..49ee6f43c 100644 --- a/.github/workflows/mcp-release.yml +++ b/.github/workflows/mcp-release.yml @@ -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 diff --git a/Cargo.lock b/Cargo.lock index 3d29700fd..280d02f7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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]] diff --git a/Cargo.toml b/Cargo.toml index 24f999ae9..d03a0dc0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/crates/dbx-mcp/Cargo.toml b/crates/dbx-mcp/Cargo.toml new file mode 100644 index 000000000..d0d4ab4a0 --- /dev/null +++ b/crates/dbx-mcp/Cargo.toml @@ -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" diff --git a/crates/dbx-mcp/src/backend.rs b/crates/dbx-mcp/src/backend.rs new file mode 100644 index 000000000..ebea4e6a4 --- /dev/null +++ b/crates/dbx-mcp/src/backend.rs @@ -0,0 +1,1109 @@ +use std::{collections::HashMap, path::Path, sync::Arc}; + +use async_trait::async_trait; +use dbx_core::{ + agent_events::{ToolCall, ToolResult}, + agent_tools::{self, AgentSqlPermissions}, + connection::AppState, + db::{redis_driver::RedisCommandResult, ColumnInfo, TableInfo}, + models::connection::{ConnectionConfig, DatabaseType}, + storage::Storage, +}; +use serde::{Deserialize, Serialize}; +use serde_json::{json, Value}; +use tokio::sync::Mutex; + +use crate::mongo::MongoCommand; + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +pub struct ConnectionSummary { + pub id: String, + pub name: String, + pub db_type: String, + pub host: String, + pub port: u16, + pub database: String, +} + +impl From<&ConnectionConfig> for ConnectionSummary { + fn from(config: &ConnectionConfig) -> Self { + let db_type = serde_json::to_value(config.db_type) + .ok() + .and_then(|value| value.as_str().map(ToOwned::to_owned)) + .unwrap_or_else(|| format!("{:?}", config.db_type).to_ascii_lowercase()); + Self { + id: config.id.clone(), + name: config.name.clone(), + db_type, + host: config.host.clone(), + port: config.port, + database: config.database.clone().unwrap_or_default(), + } + } +} + +#[async_trait] +pub trait DbxBackend: Send + Sync { + async fn load_connections(&self) -> Result, String>; + async fn execute_agent_tool( + &self, + connection: &ConnectionConfig, + database: &str, + tool_name: &str, + arguments: Value, + permissions: AgentSqlPermissions, + ) -> ToolResult; + async fn save_connections(&self, connections: &[ConnectionConfig]) -> Result<(), String>; + async fn list_tables( + &self, + connection: &ConnectionConfig, + database: &str, + schema: &str, + ) -> Result, String> { + let _ = (connection, database, schema); + Err("Table metadata is not supported by this backend.".to_string()) + } + async fn get_columns( + &self, + connection: &ConnectionConfig, + database: &str, + schema: &str, + table: &str, + ) -> Result, String> { + let _ = (connection, database, schema, table); + Err("Column metadata is not supported by this backend.".to_string()) + } + async fn execute_redis_command( + &self, + connection: &ConnectionConfig, + database: u32, + command: &str, + skip_safety_check: bool, + ) -> Result { + let _ = (connection, database, command, skip_safety_check); + Err("Redis commands are not supported by this backend.".to_string()) + } + async fn execute_mongo_command( + &self, + connection: &ConnectionConfig, + database: &str, + command: &MongoCommand, + ) -> Result { + let _ = (connection, database, command); + Err("MongoDB shell commands are not supported by this backend.".to_string()) + } + async fn bridge_request(&self, path: &str, body: Value) -> Result<(), String> { + let _ = (path, body); + Err("DBX is not running. Please start DBX first.".to_string()) + } +} + +pub struct LocalBackend { + state: Arc, + data_dir: std::path::PathBuf, +} + +#[derive(Default)] +struct WebAuthState { + session_cookie: Option, + checked: bool, +} + +pub struct WebBackend { + base_url: String, + password: String, + client: reqwest::Client, + auth: Mutex, +} + +impl WebBackend { + pub fn new(base_url: String, password: String) -> Result { + let base_url = base_url.trim().trim_end_matches('/').to_string(); + if base_url.is_empty() { + return Err("DBX_WEB_URL cannot be empty.".to_string()); + } + let client = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .build() + .map_err(|error| error.to_string())?; + Ok(Self { base_url, password, client, auth: Mutex::new(WebAuthState::default()) }) + } + + async fn ensure_auth(&self) -> Result<(), String> { + let mut auth = self.auth.lock().await; + if auth.session_cookie.is_some() || auth.checked { + return Ok(()); + } + #[derive(Deserialize)] + struct AuthCheck { + authenticated: bool, + required: bool, + setup_required: bool, + } + let response = self + .client + .get(format!("{}/api/auth/check", self.base_url)) + .send() + .await + .map_err(|error| format!("Authentication check failed: {error}"))?; + if !response.status().is_success() { + return Err(format!("Authentication check failed: {}", response.status())); + } + let check: AuthCheck = response.json().await.map_err(|error| format!("Invalid auth response: {error}"))?; + if check.setup_required { + return Err("DBX Web password setup is required before MCP Web mode can access APIs.".to_string()); + } + if !check.required || check.authenticated { + auth.checked = true; + return Ok(()); + } + if self.password.is_empty() { + return Err("DBX Web authentication is required. Set DBX_WEB_PASSWORD for MCP Web mode.".to_string()); + } + let response = self + .client + .post(format!("{}/api/auth/login", self.base_url)) + .json(&json!({ "password": self.password })) + .send() + .await + .map_err(|error| format!("Authentication failed: {error}"))?; + if !response.status().is_success() { + return Err(format!("Authentication failed: {}", response.status())); + } + let cookie = response + .headers() + .get(reqwest::header::SET_COOKIE) + .and_then(|value| value.to_str().ok()) + .and_then(extract_session_cookie) + .ok_or_else(|| "Authentication failed: DBX Web did not return a session cookie.".to_string())?; + auth.session_cookie = Some(cookie); + auth.checked = true; + Ok(()) + } + + async fn request( + &self, + method: reqwest::Method, + path: &str, + body: Option, + ) -> Result { + self.ensure_auth().await?; + let mut retried = false; + loop { + let cookie = self.auth.lock().await.session_cookie.clone(); + let mut request = self.client.request(method.clone(), format!("{}{}", self.base_url, path)); + if let Some(cookie) = cookie { + request = request.header(reqwest::header::COOKIE, format!("dbx_session={cookie}")); + } + if let Some(body) = body.as_ref() { + request = request.json(body); + } + let response = request.send().await.map_err(|error| format!("API request {path} failed: {error}"))?; + if response.status() == reqwest::StatusCode::UNAUTHORIZED && !retried && !self.password.is_empty() { + *self.auth.lock().await = WebAuthState::default(); + self.ensure_auth().await?; + retried = true; + continue; + } + if response.status().is_success() { + return Ok(response); + } + let status = response.status(); + let details = response.text().await.unwrap_or_default(); + return Err(format!("API request {path} failed: {status} {details}")); + } + } + + async fn ensure_connected(&self, connection: &ConnectionConfig) -> Result<(), String> { + self.request(reqwest::Method::POST, "/api/connection/connect", Some(json!({ "config": connection }))).await?; + Ok(()) + } +} + +impl LocalBackend { + pub async fn open(path: &Path) -> Result { + let storage = Storage::open(path).await?; + let configs = storage.load_connections().await?; + let state = Arc::new(AppState::new(storage)); + let config_map: HashMap = + configs.into_iter().map(|config| (config.id.clone(), config)).collect(); + *state.configs.write().await = config_map; + let data_dir = path.parent().unwrap_or_else(|| Path::new(".")).to_path_buf(); + Ok(Self { state, data_dir }) + } + + pub fn state(&self) -> &Arc { + &self.state + } +} + +#[async_trait] +impl DbxBackend for LocalBackend { + async fn load_connections(&self) -> Result, String> { + self.state.storage.load_connections().await + } + + async fn execute_agent_tool( + &self, + connection: &ConnectionConfig, + database: &str, + tool_name: &str, + arguments: Value, + permissions: AgentSqlPermissions, + ) -> ToolResult { + let call = ToolCall { id: format!("mcp-{tool_name}"), name: tool_name.to_string(), arguments }; + agent_tools::execute_tool(&call, &self.state, &connection.id, database, &connection.db_type, permissions).await + } + + async fn save_connections(&self, connections: &[ConnectionConfig]) -> Result<(), String> { + self.state.storage.save_connections(connections).await?; + *self.state.configs.write().await = + connections.iter().cloned().map(|config| (config.id.clone(), config)).collect(); + Ok(()) + } + + async fn list_tables( + &self, + connection: &ConnectionConfig, + database: &str, + schema: &str, + ) -> Result, String> { + dbx_core::schema::list_tables_core(&self.state, &connection.id, database, schema, None, None, None, None).await + } + + async fn get_columns( + &self, + connection: &ConnectionConfig, + database: &str, + schema: &str, + table: &str, + ) -> Result, String> { + dbx_core::schema::get_columns_core(&self.state, &connection.id, database, schema, table).await + } + + async fn execute_redis_command( + &self, + connection: &ConnectionConfig, + database: u32, + command: &str, + skip_safety_check: bool, + ) -> Result { + dbx_core::redis_ops::redis_execute_command_core( + &self.state, + &connection.id, + database, + command, + skip_safety_check, + ) + .await + } + + async fn execute_mongo_command( + &self, + connection: &ConnectionConfig, + database: &str, + command: &MongoCommand, + ) -> Result { + use dbx_core::mongo_ops; + + let connection_id = &connection.id; + match command { + MongoCommand::Version => mongo_ops::mongo_server_version_core(&self.state, connection_id, database) + .await + .map(|version| format!("| version |\n| --- |\n| {} |\n\n1 row(s)", escape_markdown_cell(&version))), + MongoCommand::Find { collection, filter, projection, sort, skip, limit } => { + let result = mongo_ops::mongo_find_documents_core( + &self.state, + connection_id, + database, + collection, + *skip, + *limit, + Some(filter), + projection.as_deref(), + sort.as_deref(), + ) + .await?; + Ok(format_mongo_documents(&result.documents)) + } + MongoCommand::Count { collection, filter, accurate } => { + let mode = if *accurate { "accurate" } else { "legacy" }; + let total = mongo_ops::mongo_count_documents_core( + &self.state, + connection_id, + database, + collection, + Some(filter), + Some(mode), + ) + .await?; + Ok(format!("| count |\n| --- |\n| {total} |\n\n1 row(s)")) + } + MongoCommand::Aggregate { collection, pipeline, options } => { + let result = mongo_ops::mongo_aggregate_documents_core( + &self.state, + connection_id, + database, + collection, + pipeline, + Some(100), + options.as_deref(), + ) + .await?; + Ok(format_mongo_documents(&result.documents)) + } + MongoCommand::Distinct { collection, field, filter } => { + let result = mongo_ops::mongo_distinct_core( + &self.state, + connection_id, + database, + collection, + field, + filter.as_deref(), + ) + .await?; + Ok(format_mongo_documents(&result.documents)) + } + MongoCommand::GetIndexes { collection } => { + let result = mongo_ops::mongo_aggregate_documents_core( + &self.state, + connection_id, + database, + collection, + r#"[{"$indexStats":{}}]"#, + Some(100), + None, + ) + .await?; + Ok(format_mongo_documents(&result.documents)) + } + MongoCommand::CollectionStats { collection, metric, scale } => { + let stats = mongo_ops::mongo_collection_stats_core( + &self.state, + connection_id, + database, + collection, + scale.clone(), + ) + .await?; + let value = serde_json::to_value(stats).map_err(|error| error.to_string())?; + if metric == "stats" { + Ok(serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string())) + } else { + let key = match metric.as_str() { + "dataSize" => "size", + "storageSize" => "storageSize", + "totalIndexSize" => "totalIndexSize", + _ => metric, + }; + let metric_value = value.get(key).cloned().unwrap_or(Value::Null); + Ok(format!("| {metric} |\n| --- |\n| {} |\n\n1 row(s)", format_query_cell(&metric_value))) + } + } + MongoCommand::Insert { collection, documents } => { + let affected = + mongo_ops::mongo_insert_documents_core(&self.state, connection_id, database, collection, documents) + .await?; + Ok(format!("Query executed. {affected} row(s) affected.")) + } + MongoCommand::Update { collection, filter, update, options, many } => { + let affected = mongo_ops::mongo_update_documents_core( + &self.state, + connection_id, + database, + collection, + filter, + update, + *many, + options.as_deref(), + ) + .await?; + Ok(format!("Query executed. {affected} row(s) affected.")) + } + MongoCommand::Delete { collection, filter, many } => { + let affected = mongo_ops::mongo_delete_documents_core( + &self.state, + connection_id, + database, + collection, + filter, + *many, + ) + .await?; + Ok(format!("Query executed. {affected} row(s) affected.")) + } + MongoCommand::CreateIndex { collection, keys, options } => { + let name = mongo_ops::mongo_create_index_core( + &self.state, + connection_id, + database, + collection, + keys, + options.as_deref(), + ) + .await?; + Ok(format!("| name |\n| --- |\n| {} |\n\n1 row(s)", escape_markdown_cell(&name))) + } + MongoCommand::DropIndexes { collection, indexes, single } => { + let result = mongo_ops::mongo_drop_indexes_core( + &self.state, + connection_id, + database, + collection, + indexes.as_deref(), + *single, + ) + .await?; + Ok(format!("Query executed. {} row(s) affected.", result.affected_rows)) + } + MongoCommand::DropCollection { collection } => { + mongo_ops::mongo_drop_collection_core(&self.state, connection_id, database, collection).await?; + Ok("Query executed. 1 row(s) affected.".to_string()) + } + } + } + + async fn bridge_request(&self, path: &str, body: Value) -> Result<(), String> { + let port = tokio::fs::read_to_string(self.data_dir.join("mcp-bridge-port")) + .await + .map_err(|_| "DBX is not running. Please start DBX first.".to_string())?; + let response = reqwest::Client::new() + .post(format!("http://127.0.0.1:{}{}", port.trim(), path)) + .json(&body) + .send() + .await + .map_err(|_| "DBX is not running. Please start DBX first.".to_string())?; + if response.status().is_success() { + Ok(()) + } else { + Err(response.text().await.unwrap_or_else(|_| "DBX bridge request failed.".to_string())) + } + } +} + +#[async_trait] +impl DbxBackend for WebBackend { + async fn load_connections(&self) -> Result, String> { + self.request(reqwest::Method::GET, "/api/connection/list", None) + .await? + .json() + .await + .map_err(|error| format!("Invalid connection list response: {error}")) + } + + async fn execute_agent_tool( + &self, + connection: &ConnectionConfig, + database: &str, + tool_name: &str, + arguments: Value, + _permissions: AgentSqlPermissions, + ) -> ToolResult { + let result = async { + if tool_name != "execute_query" { + return Err(format!("Unsupported DBX Web agent tool: {tool_name}")); + } + if connection.db_type == DatabaseType::MongoDb { + return Err( + "MongoDB shell commands in DBX Web mode are not implemented by the Rust MCP yet.".to_string() + ); + } + self.ensure_connected(connection).await?; + let sql = arguments.get("sql").and_then(Value::as_str).ok_or("Missing SQL query")?; + let max_rows = arguments.get("limit").and_then(Value::as_u64).unwrap_or(100) as usize; + let response = self + .request( + reqwest::Method::POST, + "/api/query/execute", + Some(json!({ "connectionId": connection.id, "database": database, "sql": sql })), + ) + .await?; + let query_result: dbx_core::db::QueryResult = + response.json().await.map_err(|error| format!("Invalid query response: {error}"))?; + Ok(format_query_result(&query_result, max_rows)) + } + .await; + ToolResult { + tool_call_id: format!("mcp-{tool_name}"), + tool_name: tool_name.to_string(), + content: result.as_ref().cloned().unwrap_or_else(|error| format!("Error: {error}")), + is_error: result.is_err(), + explain_data: None, + } + } + + async fn save_connections(&self, _connections: &[ConnectionConfig]) -> Result<(), String> { + Err("Connection changes are unavailable in DBX Web mode.".to_string()) + } + + async fn list_tables( + &self, + connection: &ConnectionConfig, + database: &str, + schema: &str, + ) -> Result, String> { + self.ensure_connected(connection).await?; + if connection.db_type == DatabaseType::MongoDb { + let values: Vec = self + .request( + reqwest::Method::POST, + "/api/mongo/list-collections", + Some(json!({ "connectionId": connection.id, "database": database })), + ) + .await? + .json() + .await + .map_err(|error| format!("Invalid collection list response: {error}"))?; + return Ok(values + .into_iter() + .filter_map(|value| { + let name = value + .as_str() + .map(ToOwned::to_owned) + .or_else(|| value.get("name").and_then(Value::as_str).map(ToOwned::to_owned))?; + Some(TableInfo { + name, + table_type: "COLLECTION".to_string(), + comment: None, + parent_schema: None, + parent_name: None, + }) + }) + .collect()); + } + self.request( + reqwest::Method::GET, + &format!( + "/api/schema/tables?connection_id={}&database={}&schema={}", + url_encode(&connection.id), + url_encode(database), + url_encode(schema) + ), + None, + ) + .await? + .json() + .await + .map_err(|error| format!("Invalid table list response: {error}")) + } + + async fn get_columns( + &self, + connection: &ConnectionConfig, + database: &str, + schema: &str, + table: &str, + ) -> Result, String> { + self.ensure_connected(connection).await?; + if connection.db_type == DatabaseType::MongoDb { + #[derive(Deserialize)] + struct MongoDocuments { + documents: Vec, + } + let result: MongoDocuments = self + .request( + reqwest::Method::POST, + "/api/mongo/find-documents", + Some(json!({ + "connectionId": connection.id, + "database": database, + "collection": table, + "skip": 0, + "limit": 20, + "filter": "{}", + })), + ) + .await? + .json() + .await + .map_err(|error| format!("Invalid MongoDB document response: {error}"))?; + return Ok(infer_document_columns(&result.documents)); + } + self.request( + reqwest::Method::GET, + &format!( + "/api/schema/columns?connection_id={}&database={}&schema={}&table={}", + url_encode(&connection.id), + url_encode(database), + url_encode(schema), + url_encode(table) + ), + None, + ) + .await? + .json() + .await + .map_err(|error| format!("Invalid column list response: {error}")) + } + + async fn execute_redis_command( + &self, + connection: &ConnectionConfig, + database: u32, + command: &str, + skip_safety_check: bool, + ) -> Result { + self.ensure_connected(connection).await?; + self.request( + reqwest::Method::POST, + "/api/redis/execute-command", + Some(json!({ + "connectionId": connection.id, + "db": database, + "command": command, + "skipSafetyCheck": skip_safety_check, + })), + ) + .await? + .json() + .await + .map_err(|error| format!("Invalid Redis command response: {error}")) + } + + async fn execute_mongo_command( + &self, + connection: &ConnectionConfig, + database: &str, + command: &MongoCommand, + ) -> Result { + self.ensure_connected(connection).await?; + let connection_id = &connection.id; + match command { + MongoCommand::Version => { + let version: String = self + .request( + reqwest::Method::POST, + "/api/mongo/server-version", + Some(json!({ "connectionId": connection_id, "database": database })), + ) + .await? + .json() + .await + .map_err(|error| format!("Invalid MongoDB version response: {error}"))?; + Ok(format!("| version |\n| --- |\n| {} |\n\n1 row(s)", escape_markdown_cell(&version))) + } + MongoCommand::Find { collection, filter, projection, sort, skip, limit } => { + let result = self + .request( + reqwest::Method::POST, + "/api/mongo/find-documents", + Some(json!({ + "connectionId": connection_id, + "database": database, + "collection": collection, + "skip": skip, + "limit": limit, + "filter": filter, + "projection": projection, + "sort": sort, + })), + ) + .await? + .json::() + .await + .map_err(|error| format!("Invalid MongoDB find response: {error}"))?; + Ok(format_mongo_documents(&result.documents)) + } + MongoCommand::Count { collection, filter, accurate } => { + let total: u64 = self + .request( + reqwest::Method::POST, + "/api/mongo/count-documents", + Some(json!({ + "connectionId": connection_id, + "database": database, + "collection": collection, + "filter": filter, + "mode": if *accurate { "accurate" } else { "legacy" }, + })), + ) + .await? + .json() + .await + .map_err(|error| format!("Invalid MongoDB count response: {error}"))?; + Ok(format!("| count |\n| --- |\n| {total} |\n\n1 row(s)")) + } + MongoCommand::Aggregate { collection, pipeline, options } => { + let result = self + .request( + reqwest::Method::POST, + "/api/mongo/aggregate-documents", + Some(json!({ + "connectionId": connection_id, + "database": database, + "collection": collection, + "pipelineJson": pipeline, + "maxRows": 100, + "optionsJson": options, + })), + ) + .await? + .json::() + .await + .map_err(|error| format!("Invalid MongoDB aggregate response: {error}"))?; + Ok(format_mongo_documents(&result.documents)) + } + MongoCommand::Distinct { collection, field, filter } => { + let result = self + .request( + reqwest::Method::POST, + "/api/mongo/distinct", + Some(json!({ + "connectionId": connection_id, + "database": database, + "collection": collection, + "field": field, + "filter": filter, + })), + ) + .await? + .json::() + .await + .map_err(|error| format!("Invalid MongoDB distinct response: {error}"))?; + Ok(format_mongo_documents(&result.documents)) + } + MongoCommand::GetIndexes { collection } => { + let result = self + .request( + reqwest::Method::POST, + "/api/mongo/aggregate-documents", + Some(json!({ + "connectionId": connection_id, + "database": database, + "collection": collection, + "pipelineJson": r#"[{"$indexStats":{}}]"#, + "maxRows": 100, + })), + ) + .await? + .json::() + .await + .map_err(|error| format!("Invalid MongoDB indexes response: {error}"))?; + Ok(format_mongo_documents(&result.documents)) + } + MongoCommand::CollectionStats { collection, metric, scale } => { + let value: Value = self + .request( + reqwest::Method::POST, + "/api/mongo/collection-stats", + Some(json!({ + "connectionId": connection_id, + "database": database, + "collection": collection, + "scale": scale, + })), + ) + .await? + .json() + .await + .map_err(|error| format!("Invalid MongoDB stats response: {error}"))?; + if metric == "stats" { + Ok(serde_json::to_string_pretty(&value).unwrap_or_else(|_| value.to_string())) + } else { + let key = match metric.as_str() { + "dataSize" => "size", + "storageSize" => "storageSize", + "totalIndexSize" => "totalIndexSize", + _ => metric, + }; + let metric_value = value.get(key).cloned().unwrap_or(Value::Null); + Ok(format!("| {metric} |\n| --- |\n| {} |\n\n1 row(s)", format_query_cell(&metric_value))) + } + } + MongoCommand::Insert { collection, documents } => { + let value: Value = self + .request( + reqwest::Method::POST, + "/api/mongo/insert-documents", + Some(json!({ + "connectionId": connection_id, + "database": database, + "collection": collection, + "docsJson": documents, + })), + ) + .await? + .json() + .await + .map_err(|error| format!("Invalid MongoDB insert response: {error}"))?; + Ok(format_affected_rows(&value)) + } + MongoCommand::Update { collection, filter, update, options, many } => { + let value: Value = self + .request( + reqwest::Method::POST, + "/api/mongo/update-documents", + Some(json!({ + "connectionId": connection_id, + "database": database, + "collection": collection, + "filterJson": filter, + "updateJson": update, + "many": many, + "optionsJson": options, + })), + ) + .await? + .json() + .await + .map_err(|error| format!("Invalid MongoDB update response: {error}"))?; + Ok(format_affected_rows(&value)) + } + MongoCommand::Delete { collection, filter, many } => { + let value: Value = self + .request( + reqwest::Method::POST, + "/api/mongo/delete-documents", + Some(json!({ + "connectionId": connection_id, + "database": database, + "collection": collection, + "filterJson": filter, + "many": many, + })), + ) + .await? + .json() + .await + .map_err(|error| format!("Invalid MongoDB delete response: {error}"))?; + Ok(format_affected_rows(&value)) + } + MongoCommand::CreateIndex { collection, keys, options } => { + let value: Value = self + .request( + reqwest::Method::POST, + "/api/mongo/create-index", + Some(json!({ + "connectionId": connection_id, + "database": database, + "collection": collection, + "keysJson": keys, + "optionsJson": options, + })), + ) + .await? + .json() + .await + .map_err(|error| format!("Invalid MongoDB create index response: {error}"))?; + Ok(format!( + "| name |\n| --- |\n| {} |\n\n1 row(s)", + escape_markdown_cell(value.get("name").and_then(Value::as_str).unwrap_or("")) + )) + } + MongoCommand::DropIndexes { collection, indexes, single } => { + let value: Value = self + .request( + reqwest::Method::POST, + "/api/mongo/drop-indexes", + Some(json!({ + "connectionId": connection_id, + "database": database, + "collection": collection, + "indexesJson": indexes, + "single": single, + })), + ) + .await? + .json() + .await + .map_err(|error| format!("Invalid MongoDB drop indexes response: {error}"))?; + Ok(format_affected_rows(&value)) + } + MongoCommand::DropCollection { collection } => { + self.request( + reqwest::Method::POST, + "/api/mongo/drop-collection", + Some(json!({ "connectionId": connection_id, "database": database, "collection": collection })), + ) + .await?; + Ok("Query executed. 1 row(s) affected.".to_string()) + } + } + } +} + +#[derive(Deserialize)] +struct WebMongoDocuments { + documents: Vec, +} + +fn extract_session_cookie(header: &str) -> Option { + header + .split(';') + .find_map(|part| part.trim().strip_prefix("dbx_session=").map(ToOwned::to_owned)) + .filter(|value| !value.is_empty()) +} + +fn url_encode(value: &str) -> String { + url::form_urlencoded::byte_serialize(value.as_bytes()).collect() +} + +fn format_query_result(result: &dbx_core::db::QueryResult, max_rows: usize) -> String { + if result.columns.is_empty() { + return format!("Query executed. {} row(s) affected.", result.affected_rows); + } + let rows = result + .rows + .iter() + .take(max_rows) + .map(|row| row.iter().map(format_query_cell).collect::>()) + .collect::>(); + let mut output = markdown_table(&result.columns, &rows); + output.push_str(&format!("\n\n{} row(s)", rows.len())); + output +} + +fn format_query_cell(value: &Value) -> String { + match value { + Value::Null => "NULL".to_string(), + Value::String(value) => value.clone(), + Value::Array(_) | Value::Object(_) => serde_json::to_string(value).unwrap_or_else(|_| value.to_string()), + value => value.to_string(), + } +} + +fn format_mongo_documents(documents: &[Value]) -> String { + if documents.is_empty() { + return "Query returned 0 rows.".to_string(); + } + let mut headers = std::collections::BTreeSet::new(); + for document in documents { + if let Some(object) = document.as_object() { + headers.extend(object.keys().cloned()); + } else { + headers.insert("value".to_string()); + } + } + let headers = headers.into_iter().collect::>(); + let rows = documents + .iter() + .map(|document| { + headers + .iter() + .map(|header| { + document + .as_object() + .and_then(|object| object.get(header)) + .or_else(|| (header == "value").then_some(document)) + .map(format_query_cell) + .unwrap_or_else(|| "NULL".to_string()) + }) + .collect::>() + }) + .collect::>(); + format!("{}\n\n{} row(s)", markdown_table(&headers, &rows), documents.len()) +} + +fn format_affected_rows(value: &Value) -> String { + let affected = + value.get("affected_rows").or_else(|| value.get("affectedRows")).and_then(Value::as_u64).unwrap_or(0); + format!("Query executed. {affected} row(s) affected.") +} + +fn markdown_table(headers: &[String], rows: &[Vec]) -> String { + let mut output = format!( + "| {} |\n| {} |", + headers.iter().map(|value| escape_markdown_cell(value)).collect::>().join(" | "), + vec!["---"; headers.len()].join(" | ") + ); + for row in rows { + output.push_str(&format!( + "\n| {} |", + row.iter().map(|value| escape_markdown_cell(value)).collect::>().join(" | ") + )); + } + output +} + +fn escape_markdown_cell(value: &str) -> String { + value.replace('|', "\\|").replace(['\r', '\n'], " ") +} + +fn infer_document_columns(documents: &[Value]) -> Vec { + let mut columns = std::collections::BTreeMap::::new(); + for document in documents { + let Some(object) = document.as_object() else { continue }; + for (name, value) in object { + columns.entry(name.clone()).or_insert_with(|| json_type_name(value).to_string()); + } + } + columns + .into_iter() + .map(|(name, data_type)| ColumnInfo { + name, + data_type, + is_nullable: true, + column_default: None, + is_primary_key: false, + extra: None, + comment: None, + numeric_precision: None, + numeric_scale: None, + character_maximum_length: None, + enum_values: None, + character_set: None, + collation: None, + }) + .collect() +} + +fn json_type_name(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "boolean", + Value::Number(number) if number.is_i64() || number.is_u64() => "integer", + Value::Number(_) => "number", + Value::String(_) => "string", + Value::Array(_) => "array", + Value::Object(_) => "object", + } +} + +pub fn parse_database_type(value: &str) -> Result { + serde_json::from_value(serde_json::Value::String(value.trim().to_ascii_lowercase())) + .map_err(|_| format!("Unsupported database type: {value}")) +} + +#[derive(Debug, Deserialize, Serialize)] +struct NewConnectionConfig { + id: String, + name: String, + db_type: DatabaseType, + host: String, + port: u16, + username: String, + password: String, + database: Option, + ssl: bool, + driver_profile: Option, +} + +pub fn new_connection_config( + id: String, + name: String, + db_type: DatabaseType, + host: String, + port: u16, + username: String, + password: String, + database: Option, + ssl: bool, + driver_profile: Option, +) -> Result { + let minimal = + NewConnectionConfig { id, name, db_type, host, port, username, password, database, ssl, driver_profile }; + serde_json::from_value(serde_json::to_value(minimal).map_err(|error| error.to_string())?) + .map_err(|error| error.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_database_type_using_dbx_protocol_names() { + assert_eq!(parse_database_type("Postgres").unwrap(), DatabaseType::Postgres); + assert_eq!(parse_database_type("mongodb").unwrap(), DatabaseType::MongoDb); + assert!(parse_database_type("unknown").is_err()); + } +} diff --git a/crates/dbx-mcp/src/lib.rs b/crates/dbx-mcp/src/lib.rs new file mode 100644 index 000000000..dba136f25 --- /dev/null +++ b/crates/dbx-mcp/src/lib.rs @@ -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}; diff --git a/crates/dbx-mcp/src/main.rs b/crates/dbx-mcp/src/main.rs new file mode 100644 index 000000000..474238bf9 --- /dev/null +++ b/crates/dbx-mcp/src/main.rs @@ -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> { + let backend: Arc = 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(()) +} diff --git a/crates/dbx-mcp/src/mongo.rs b/crates/dbx-mcp/src/mongo.rs new file mode 100644 index 000000000..da9d7eb86 --- /dev/null +++ b/crates/dbx-mcp/src/mongo.rs @@ -0,0 +1,491 @@ +use serde_json::Value; + +#[derive(Debug, Clone, PartialEq)] +pub enum MongoCommand { + Version, + Find { collection: String, filter: String, projection: Option, sort: Option, skip: u64, limit: i64 }, + Count { collection: String, filter: String, accurate: bool }, + Aggregate { collection: String, pipeline: String, options: Option }, + Distinct { collection: String, field: String, filter: Option }, + GetIndexes { collection: String }, + CollectionStats { collection: String, metric: String, scale: Option }, + Insert { collection: String, documents: String }, + Update { collection: String, filter: String, update: String, options: Option, many: bool }, + Delete { collection: String, filter: String, many: bool }, + CreateIndex { collection: String, keys: String, options: Option }, + DropIndexes { collection: String, indexes: Option, 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 { + 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::() + .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..".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)> { + 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)>, 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 { + if args.len() != 1 { + return Err(format!("MongoDB {name}() requires one integer.")); + } + let value = + args[0].trim().parse::().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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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()); + } +} diff --git a/crates/dbx-mcp/src/paths.rs b/crates/dbx-mcp/src/paths.rs new file mode 100644 index 000000000..0966711b3 --- /dev/null +++ b/crates/dbx-mcp/src/paths.rs @@ -0,0 +1,46 @@ +use std::path::PathBuf; + +pub const STORAGE_DB_FILE_NAME: &str = "dbx.db"; + +pub fn app_data_dir() -> Result { + 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 { + 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"), + } + } +} diff --git a/crates/dbx-mcp/src/server.rs b/crates/dbx-mcp/src/server.rs new file mode 100644 index 000000000..9efdc876d --- /dev/null +++ b/crates/dbx-mcp/src/server.rs @@ -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, + #[schemars(description = "Name of the DBX connection")] + pub connection_name: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ListTablesRequest { + #[serde(flatten)] + pub selector: ConnectionSelector, + #[schemars(description = "Database name")] + pub database: Option, + #[schemars(description = "Schema name")] + pub schema: Option, +} + +#[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, + #[schemars(description = "Schema name")] + pub schema: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ExecuteQueryRequest { + #[serde(flatten)] + pub selector: ConnectionSelector, + #[schemars(description = "Database name")] + pub database: Option, + #[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, + #[serde(default)] + pub username: String, + #[serde(default)] + pub password: String, + pub database: Option, + #[serde(default)] + pub ssl: bool, + pub driver_profile: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct RemoveConnectionRequest { + pub connection_name: String, + pub connection_id: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ExecuteRedisCommandRequest { + #[serde(flatten)] + pub selector: ConnectionSelector, + #[schemars(description = "Redis logical database number")] + pub db: Option, + #[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, + pub schema: Option, + #[schemars(description = "Specific table names to include")] + pub tables: Option>, + #[schemars(description = "Maximum number of tables to include, from 1 to 20")] + pub max_tables: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct OpenTableRequest { + #[serde(flatten)] + pub selector: ConnectionSelector, + pub table: String, + pub database: Option, + pub schema: Option, +} + +#[derive(Debug, Deserialize, schemars::JsonSchema)] +pub struct ExecuteAndShowRequest { + #[serde(flatten)] + pub selector: ConnectionSelector, + pub sql: String, + pub database: Option, +} + +#[derive(Clone)] +pub struct DbxMcpServer { + backend: Arc, + scope: McpScope, + tool_router: ToolRouter, +} + +#[derive(Clone, Debug, Default)] +pub struct McpScope { + pub connection_id: Option, + pub connection_name: Option, + pub database: Option, +} + +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) -> 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, 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, + ) -> 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::>(); + 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) -> 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::>() + .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) -> 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) -> 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, + ) -> 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) -> 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::>(); + let mut selected = if requested.is_empty() { + available.iter().collect::>() + } else { + available.iter().filter(|table| requested.contains(&table.name.to_ascii_lowercase())).collect::>() + }; + 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) -> 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) -> 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::>(); + 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) -> 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) -> 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, 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, + 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 { + 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::>(); + 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) -> CallToolResult { + CallToolResult::success(vec![ContentBlock::text(value)]) +} + +fn tool_error(code: &str, message: impl Into) -> 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 { + 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 { + 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 { + std::env::var(name).ok().map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) +} + +fn default_port(db_type: &str) -> Option { + 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::>() + .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::>(); + markdown_table(&["Column", "Type", "Nullable", "Default", "Comment"], &rows) +} + +fn markdown_table(headers: &[&str], rows: &[Vec]) -> 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::>().join(" | "))); + } + output +} + +fn escape_cell(value: &str) -> String { + value.replace('|', "\\|").replace('\n', " ") +} + +fn redis_database(connection: &dbx_core::models::connection::ConnectionConfig) -> Option { + connection.database.as_deref().and_then(parse_redis_database) +} + +fn parse_redis_database(value: &str) -> Option { + 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)], + 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, + } + + #[async_trait] + impl DbxBackend for FakeBackend { + async fn load_connections(&self) -> Result, 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::>(); + 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::>(); + 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")); + } +} diff --git a/crates/dbx-mcp/tests/local.rs b/crates/dbx-mcp/tests/local.rs new file mode 100644 index 000000000..3f6b62d8b --- /dev/null +++ b/crates/dbx-mcp/tests/local.rs @@ -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::() + .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, sql: &str) -> String { + let arguments = json!({ + "connection_id": "mongo-e2e", + "database": "dbx_mcp_test", + "sql": sql, + }) + .as_object() + .cloned() + .unwrap_or_else(Map::::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 +} diff --git a/crates/dbx-mcp/tests/protocol.rs b/crates/dbx-mcp/tests/protocol.rs new file mode 100644 index 000000000..bb709d9aa --- /dev/null +++ b/crates/dbx-mcp/tests/protocol.rs @@ -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, 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::>(); + 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(); +} diff --git a/packages/cli/package.json b/packages/cli/package.json index b950be824..7f50fbae4 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -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", diff --git a/packages/mcp-darwin-arm64/README.md b/packages/mcp-darwin-arm64/README.md new file mode 100644 index 000000000..6400d9942 --- /dev/null +++ b/packages/mcp-darwin-arm64/README.md @@ -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. diff --git a/packages/mcp-darwin-arm64/bin/dbx-mcp b/packages/mcp-darwin-arm64/bin/dbx-mcp new file mode 100755 index 000000000..cf7d4d0d9 Binary files /dev/null and b/packages/mcp-darwin-arm64/bin/dbx-mcp differ diff --git a/packages/mcp-darwin-arm64/package.json b/packages/mcp-darwin-arm64/package.json new file mode 100644 index 000000000..cf93974a8 --- /dev/null +++ b/packages/mcp-darwin-arm64/package.json @@ -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" + ] +} diff --git a/packages/mcp-darwin-x64/README.md b/packages/mcp-darwin-x64/README.md new file mode 100644 index 000000000..504418cf1 --- /dev/null +++ b/packages/mcp-darwin-x64/README.md @@ -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. diff --git a/packages/mcp-darwin-x64/package.json b/packages/mcp-darwin-x64/package.json new file mode 100644 index 000000000..2d2828c3b --- /dev/null +++ b/packages/mcp-darwin-x64/package.json @@ -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" + ] +} diff --git a/packages/mcp-linux-arm64-gnu/README.md b/packages/mcp-linux-arm64-gnu/README.md new file mode 100644 index 000000000..bb0528207 --- /dev/null +++ b/packages/mcp-linux-arm64-gnu/README.md @@ -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. diff --git a/packages/mcp-linux-arm64-gnu/package.json b/packages/mcp-linux-arm64-gnu/package.json new file mode 100644 index 000000000..892258194 --- /dev/null +++ b/packages/mcp-linux-arm64-gnu/package.json @@ -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" + ] +} diff --git a/packages/mcp-linux-x64-gnu/README.md b/packages/mcp-linux-x64-gnu/README.md new file mode 100644 index 000000000..1727c54a7 --- /dev/null +++ b/packages/mcp-linux-x64-gnu/README.md @@ -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. diff --git a/packages/mcp-linux-x64-gnu/package.json b/packages/mcp-linux-x64-gnu/package.json new file mode 100644 index 000000000..791e6e706 --- /dev/null +++ b/packages/mcp-linux-x64-gnu/package.json @@ -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" + ] +} diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 50715b9f0..98fa23bde 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -7,7 +7,7 @@ MCP server for [DBX](https://github.com/t8y2/dbx) — lets AI agents (Claude Cod ## Features - **Zero config** — Automatically reads your DBX connections (including passwords from system keyring) -- **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 diff --git a/packages/mcp-server/bin/dbx-mcp-server.js b/packages/mcp-server/bin/dbx-mcp-server.js new file mode 100755 index 000000000..8fbd8e230 --- /dev/null +++ b/packages/mcp-server/bin/dbx-mcp-server.js @@ -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); +} diff --git a/packages/mcp-server/package.json b/packages/mcp-server/package.json index c1ab5d37f..a15d706e3 100644 --- a/packages/mcp-server/package.json +++ b/packages/mcp-server/package.json @@ -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" } diff --git a/packages/mcp-server/server.json b/packages/mcp-server/server.json index f31f2b249..368a87534 100644 --- a/packages/mcp-server/server.json +++ b/packages/mcp-server/server.json @@ -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" } diff --git a/packages/mcp-server/tests/bin.test.ts b/packages/mcp-server/tests/bin.test.ts index b3a455795..c4055960c 100644 --- a/packages/mcp-server/tests/bin.test.ts +++ b/packages/mcp-server/tests/bin.test.ts @@ -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); diff --git a/packages/mcp-win32-arm64/README.md b/packages/mcp-win32-arm64/README.md new file mode 100644 index 000000000..0e5e7aff9 --- /dev/null +++ b/packages/mcp-win32-arm64/README.md @@ -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. diff --git a/packages/mcp-win32-arm64/package.json b/packages/mcp-win32-arm64/package.json new file mode 100644 index 000000000..d383ebffd --- /dev/null +++ b/packages/mcp-win32-arm64/package.json @@ -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" + ] +} diff --git a/packages/mcp-win32-x64/README.md b/packages/mcp-win32-x64/README.md new file mode 100644 index 000000000..90eb81296 --- /dev/null +++ b/packages/mcp-win32-x64/README.md @@ -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. diff --git a/packages/mcp-win32-x64/package.json b/packages/mcp-win32-x64/package.json new file mode 100644 index 000000000..bb24148b7 --- /dev/null +++ b/packages/mcp-win32-x64/package.json @@ -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" + ] +} diff --git a/packages/mongo-shell/package.json b/packages/mongo-shell/package.json index 3ba14273e..6a1b53434 100644 --- a/packages/mongo-shell/package.json +++ b/packages/mongo-shell/package.json @@ -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", diff --git a/packages/node-core/package.json b/packages/node-core/package.json index ee088005b..566bc5fe5 100644 --- a/packages/node-core/package.json +++ b/packages/node-core/package.json @@ -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": [ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 451ba8f0e..5f6f100cb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/scripts/release.mjs b/scripts/release.mjs index e39fa00ba..11e8fd47d 100644 --- a/scripts/release.mjs +++ b/scripts/release.mjs @@ -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)];