feat(mcp): run server from Rust binary
This commit is contained in:
parent
c19c4b9be6
commit
8d7dc695bb
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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]]
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
[package]
|
||||
name = "dbx-mcp"
|
||||
version = "0.4.33"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[[bin]]
|
||||
name = "dbx-mcp"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
async-trait = "0.1"
|
||||
dbx-core = { path = "../dbx-core", default-features = false }
|
||||
rmcp = { version = "2.2.0", features = ["client", "transport-io"] }
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
json5 = "0.4"
|
||||
schemars = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["fs", "macros", "rt-multi-thread"] }
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
url = "2"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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};
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use dbx_mcp::{DbxBackend, DbxMcpServer, LocalBackend, WebBackend};
|
||||
use rmcp::ServiceExt;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let backend: Arc<dyn DbxBackend> = if let Ok(base_url) = std::env::var("DBX_WEB_URL") {
|
||||
Arc::new(
|
||||
WebBackend::new(base_url, std::env::var("DBX_WEB_PASSWORD").unwrap_or_default())
|
||||
.map_err(std::io::Error::other)?,
|
||||
)
|
||||
} else {
|
||||
let db_path = dbx_mcp::paths::storage_db_path().map_err(std::io::Error::other)?;
|
||||
Arc::new(LocalBackend::open(&db_path).await.map_err(std::io::Error::other)?)
|
||||
};
|
||||
let service = DbxMcpServer::new(backend).serve(rmcp::transport::stdio()).await?;
|
||||
service.waiting().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -0,0 +1,491 @@
|
|||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum MongoCommand {
|
||||
Version,
|
||||
Find { collection: String, filter: String, projection: Option<String>, sort: Option<String>, skip: u64, limit: i64 },
|
||||
Count { collection: String, filter: String, accurate: bool },
|
||||
Aggregate { collection: String, pipeline: String, options: Option<String> },
|
||||
Distinct { collection: String, field: String, filter: Option<String> },
|
||||
GetIndexes { collection: String },
|
||||
CollectionStats { collection: String, metric: String, scale: Option<serde_json::Number> },
|
||||
Insert { collection: String, documents: String },
|
||||
Update { collection: String, filter: String, update: String, options: Option<String>, many: bool },
|
||||
Delete { collection: String, filter: String, many: bool },
|
||||
CreateIndex { collection: String, keys: String, options: Option<String> },
|
||||
DropIndexes { collection: String, indexes: Option<String>, single: bool },
|
||||
DropCollection { collection: String },
|
||||
}
|
||||
|
||||
impl MongoCommand {
|
||||
pub fn is_mutating(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
Self::Insert { .. }
|
||||
| Self::Update { .. }
|
||||
| Self::Delete { .. }
|
||||
| Self::CreateIndex { .. }
|
||||
| Self::DropIndexes { .. }
|
||||
| Self::DropCollection { .. }
|
||||
) || matches!(self, Self::Aggregate { pipeline, .. } if aggregate_writes(pipeline))
|
||||
}
|
||||
|
||||
pub fn is_dangerous(&self) -> bool {
|
||||
matches!(self, Self::DropCollection { .. })
|
||||
|| matches!(self, Self::DropIndexes { indexes: None, single: false, .. })
|
||||
|| matches!(self, Self::Aggregate { pipeline, .. } if aggregate_writes(pipeline))
|
||||
}
|
||||
|
||||
pub fn has_empty_filter(&self) -> bool {
|
||||
match self {
|
||||
Self::Update { filter, .. } | Self::Delete { filter, .. } => is_empty_object(filter),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse(input: &str) -> Result<MongoCommand, String> {
|
||||
let source = input.trim().trim_end_matches(';').trim();
|
||||
if source.eq_ignore_ascii_case("db.version()") {
|
||||
return Ok(MongoCommand::Version);
|
||||
}
|
||||
let (collection, prefix_end) = parse_collection_prefix(source)?;
|
||||
|
||||
if let Some((args, tail)) = method_call(source, prefix_end, "find") {
|
||||
let filter = normalized_json(args.first().map(String::as_str).unwrap_or("{}"))?;
|
||||
let projection =
|
||||
if args.get(1).is_some_and(|arg| !arg.trim().is_empty()) { Some(normalized_json(&args[1])?) } else { None };
|
||||
if args.len() > 2 {
|
||||
return Err("MongoDB find() accepts at most filter and projection arguments.".to_string());
|
||||
}
|
||||
let mut sort = None;
|
||||
let mut skip = 0;
|
||||
let mut limit = 100;
|
||||
for (name, call_args) in chained_calls(&tail)? {
|
||||
match name.as_str() {
|
||||
"sort" => sort = Some(normalized_json(call_args.first().map(String::as_str).unwrap_or("{}"))?),
|
||||
"skip" => skip = parse_integer(&call_args, "skip")? as u64,
|
||||
"limit" => limit = parse_integer(&call_args, "limit")?,
|
||||
"count" if call_args.is_empty() => {
|
||||
return Ok(MongoCommand::Count { collection, filter, accurate: false });
|
||||
}
|
||||
_ => return Err(format!("Unsupported MongoDB find() chain: {name}()")),
|
||||
}
|
||||
}
|
||||
return Ok(MongoCommand::Find { collection, filter, projection, sort, skip, limit });
|
||||
}
|
||||
|
||||
for (method, accurate) in [("countDocuments", true), ("count", false)] {
|
||||
if let Some((args, tail)) = method_call(source, prefix_end, method) {
|
||||
if !tail.is_empty() || args.len() > 1 {
|
||||
return Err(format!("Invalid MongoDB {method}() command."));
|
||||
}
|
||||
return Ok(MongoCommand::Count {
|
||||
collection,
|
||||
filter: normalized_json(args.first().map(String::as_str).unwrap_or("{}"))?,
|
||||
accurate,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((args, tail)) = method_call(source, prefix_end, "aggregate") {
|
||||
if !tail.is_empty() || !(1..=2).contains(&args.len()) {
|
||||
return Err("Invalid MongoDB aggregate() command.".to_string());
|
||||
}
|
||||
let pipeline = normalized_json(&args[0])?;
|
||||
if !parse_json_value(&pipeline).is_some_and(|value| value.is_array()) {
|
||||
return Err("MongoDB aggregate() requires a pipeline array.".to_string());
|
||||
}
|
||||
let options = args.get(1).filter(|arg| !arg.trim().is_empty()).map(|arg| normalized_json(arg)).transpose()?;
|
||||
return Ok(MongoCommand::Aggregate { collection, pipeline, options });
|
||||
}
|
||||
|
||||
if let Some((args, tail)) = method_call(source, prefix_end, "distinct") {
|
||||
if !tail.is_empty() || !(1..=2).contains(&args.len()) {
|
||||
return Err("Invalid MongoDB distinct() command.".to_string());
|
||||
}
|
||||
let field = parse_string_arg(&args[0])?;
|
||||
let filter = args.get(1).filter(|arg| !arg.trim().is_empty()).map(|arg| normalized_json(arg)).transpose()?;
|
||||
return Ok(MongoCommand::Distinct { collection, field, filter });
|
||||
}
|
||||
|
||||
if let Some((args, tail)) = method_call(source, prefix_end, "getIndexes") {
|
||||
if !tail.is_empty() || !args.is_empty() {
|
||||
return Err("Invalid MongoDB getIndexes() command.".to_string());
|
||||
}
|
||||
return Ok(MongoCommand::GetIndexes { collection });
|
||||
}
|
||||
|
||||
for metric in ["stats", "dataSize", "storageSize", "totalIndexSize"] {
|
||||
if let Some((args, tail)) = method_call(source, prefix_end, metric) {
|
||||
if !tail.is_empty() || args.len() > 1 {
|
||||
return Err(format!("Invalid MongoDB {metric}() command."));
|
||||
}
|
||||
let scale = args
|
||||
.first()
|
||||
.filter(|arg| !arg.trim().is_empty())
|
||||
.map(|arg| {
|
||||
arg.trim()
|
||||
.parse::<f64>()
|
||||
.ok()
|
||||
.and_then(serde_json::Number::from_f64)
|
||||
.ok_or_else(|| format!("Invalid {metric} scale."))
|
||||
})
|
||||
.transpose()?;
|
||||
return Ok(MongoCommand::CollectionStats { collection, metric: metric.to_string(), scale });
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((args, tail)) = method_call(source, prefix_end, "insertOne") {
|
||||
if !tail.is_empty() || args.len() != 1 {
|
||||
return Err("Invalid MongoDB insertOne() command.".to_string());
|
||||
}
|
||||
return Ok(MongoCommand::Insert { collection, documents: normalized_json(&args[0])? });
|
||||
}
|
||||
if let Some((args, tail)) = method_call(source, prefix_end, "insertMany") {
|
||||
if !tail.is_empty() || args.len() != 1 {
|
||||
return Err("Invalid MongoDB insertMany() command.".to_string());
|
||||
}
|
||||
let documents = normalized_json(&args[0])?;
|
||||
if !parse_json_value(&documents).is_some_and(|value| value.is_array()) {
|
||||
return Err("MongoDB insertMany() requires an array.".to_string());
|
||||
}
|
||||
return Ok(MongoCommand::Insert { collection, documents });
|
||||
}
|
||||
|
||||
for (method, many) in [("updateOne", false), ("updateMany", true)] {
|
||||
if let Some((args, tail)) = method_call(source, prefix_end, method) {
|
||||
if !tail.is_empty() || !(2..=3).contains(&args.len()) {
|
||||
return Err(format!("Invalid MongoDB {method}() command."));
|
||||
}
|
||||
return Ok(MongoCommand::Update {
|
||||
collection,
|
||||
filter: normalized_json(&args[0])?,
|
||||
update: normalized_json(&args[1])?,
|
||||
options: args
|
||||
.get(2)
|
||||
.filter(|arg| !arg.trim().is_empty())
|
||||
.map(|arg| normalized_json(arg))
|
||||
.transpose()?,
|
||||
many,
|
||||
});
|
||||
}
|
||||
}
|
||||
for (method, many) in [("deleteOne", false), ("deleteMany", true)] {
|
||||
if let Some((args, tail)) = method_call(source, prefix_end, method) {
|
||||
if !tail.is_empty() || args.len() != 1 {
|
||||
return Err(format!("Invalid MongoDB {method}() command."));
|
||||
}
|
||||
return Ok(MongoCommand::Delete { collection, filter: normalized_json(&args[0])?, many });
|
||||
}
|
||||
}
|
||||
if let Some((args, tail)) = method_call(source, prefix_end, "createIndex") {
|
||||
if !tail.is_empty() || !(1..=2).contains(&args.len()) {
|
||||
return Err("Invalid MongoDB createIndex() command.".to_string());
|
||||
}
|
||||
return Ok(MongoCommand::CreateIndex {
|
||||
collection,
|
||||
keys: normalized_json(&args[0])?,
|
||||
options: args.get(1).filter(|arg| !arg.trim().is_empty()).map(|arg| normalized_json(arg)).transpose()?,
|
||||
});
|
||||
}
|
||||
if let Some((args, tail)) = method_call(source, prefix_end, "dropIndex") {
|
||||
if !tail.is_empty() || args.len() != 1 {
|
||||
return Err("Invalid MongoDB dropIndex() command.".to_string());
|
||||
}
|
||||
return Ok(MongoCommand::DropIndexes { collection, indexes: Some(normalized_json(&args[0])?), single: true });
|
||||
}
|
||||
if let Some((args, tail)) = method_call(source, prefix_end, "dropIndexes") {
|
||||
if !tail.is_empty() || args.len() > 1 {
|
||||
return Err("Invalid MongoDB dropIndexes() command.".to_string());
|
||||
}
|
||||
return Ok(MongoCommand::DropIndexes {
|
||||
collection,
|
||||
indexes: args.first().filter(|arg| !arg.trim().is_empty()).map(|arg| normalized_json(arg)).transpose()?,
|
||||
single: false,
|
||||
});
|
||||
}
|
||||
if let Some((args, tail)) = method_call(source, prefix_end, "drop") {
|
||||
if !tail.is_empty() || !args.is_empty() {
|
||||
return Err("Invalid MongoDB drop() command.".to_string());
|
||||
}
|
||||
return Ok(MongoCommand::DropCollection { collection });
|
||||
}
|
||||
|
||||
Err("Unsupported MongoDB shell command.".to_string())
|
||||
}
|
||||
|
||||
fn parse_collection_prefix(source: &str) -> Result<(String, usize), String> {
|
||||
if !source.get(..3).is_some_and(|prefix| prefix.eq_ignore_ascii_case("db.")) {
|
||||
return Err("MongoDB command must start with db.<collection>.".to_string());
|
||||
}
|
||||
let rest = &source[3..];
|
||||
if rest.starts_with("getCollection") {
|
||||
let open = rest.find('(').ok_or("Invalid db.getCollection() command.")?;
|
||||
let close = matching_paren(rest, open).ok_or("Invalid db.getCollection() command.")?;
|
||||
let args = split_top_level(&rest[open + 1..close]);
|
||||
if args.len() != 1 {
|
||||
return Err("db.getCollection() requires one collection name.".to_string());
|
||||
}
|
||||
let collection = parse_string_arg(&args[0])?;
|
||||
let end = 3 + close + 1;
|
||||
let suffix = &source[end..];
|
||||
let trimmed = suffix.trim_start();
|
||||
if !trimmed.starts_with('.') {
|
||||
return Err("MongoDB collection method is required.".to_string());
|
||||
}
|
||||
return Ok((collection, end + suffix.len() - trimmed.len()));
|
||||
}
|
||||
let collection_end = rest
|
||||
.char_indices()
|
||||
.find_map(|(index, ch)| (ch == '.' || ch.is_whitespace()).then_some(index))
|
||||
.ok_or("MongoDB collection method is required.")?;
|
||||
let collection = &rest[..collection_end];
|
||||
if collection.is_empty() {
|
||||
return Err("Invalid MongoDB collection name.".to_string());
|
||||
}
|
||||
let suffix = &rest[collection_end..];
|
||||
let dot = suffix.find('.').ok_or("MongoDB collection method is required.")?;
|
||||
if !suffix[..dot].trim().is_empty() {
|
||||
return Err("Invalid MongoDB collection name.".to_string());
|
||||
}
|
||||
Ok((collection.to_string(), 3 + collection_end + dot))
|
||||
}
|
||||
|
||||
fn method_call(source: &str, prefix_end: usize, method: &str) -> Option<(Vec<String>, String)> {
|
||||
let raw_suffix = &source[prefix_end..];
|
||||
let suffix = raw_suffix.trim_start();
|
||||
let whitespace = raw_suffix.len() - suffix.len();
|
||||
let expected = format!(".{method}");
|
||||
if !suffix.starts_with(&expected) || !suffix[expected.len()..].starts_with('(') {
|
||||
return None;
|
||||
}
|
||||
let open = prefix_end + whitespace + expected.len();
|
||||
let close = matching_paren(source, open)?;
|
||||
Some((split_top_level(&source[open + 1..close]), source[close + 1..].trim().to_string()))
|
||||
}
|
||||
|
||||
fn chained_calls(chain: &str) -> Result<Vec<(String, Vec<String>)>, String> {
|
||||
let mut rest = chain.trim();
|
||||
let mut calls = Vec::new();
|
||||
while !rest.is_empty() {
|
||||
let Some(rest_after_dot) = rest.strip_prefix('.') else {
|
||||
return Err("Invalid MongoDB method chain.".to_string());
|
||||
};
|
||||
let open = rest_after_dot.find('(').ok_or("Invalid MongoDB method chain.")?;
|
||||
let name = rest_after_dot[..open].trim().to_string();
|
||||
let close = matching_paren(rest_after_dot, open).ok_or("Invalid MongoDB method chain.")?;
|
||||
calls.push((name, split_top_level(&rest_after_dot[open + 1..close])));
|
||||
rest = rest_after_dot[close + 1..].trim();
|
||||
}
|
||||
Ok(calls)
|
||||
}
|
||||
|
||||
fn parse_integer(args: &[String], name: &str) -> Result<i64, String> {
|
||||
if args.len() != 1 {
|
||||
return Err(format!("MongoDB {name}() requires one integer."));
|
||||
}
|
||||
let value =
|
||||
args[0].trim().parse::<i64>().map_err(|_| format!("MongoDB {name}() requires a non-negative integer."))?;
|
||||
if value < 0 {
|
||||
return Err(format!("MongoDB {name}() requires a non-negative integer."));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
fn parse_string_arg(arg: &str) -> Result<String, String> {
|
||||
let value = parse_json_value(&normalized_json(arg)?).ok_or("Invalid MongoDB string argument.")?;
|
||||
value.as_str().map(ToOwned::to_owned).ok_or_else(|| "MongoDB argument must be a string.".to_string())
|
||||
}
|
||||
|
||||
fn normalized_json(input: &str) -> Result<String, String> {
|
||||
let transformed = transform_shell_constructors(input.trim())?;
|
||||
let value: Value =
|
||||
json5::from_str(&transformed).map_err(|error| format!("Invalid MongoDB JSON argument: {error}"))?;
|
||||
serde_json::to_string(&value).map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn transform_shell_constructors(input: &str) -> Result<String, String> {
|
||||
let mut output = String::with_capacity(input.len());
|
||||
let mut index = 0;
|
||||
while index < input.len() {
|
||||
let rest = &input[index..];
|
||||
let constructor = if rest.starts_with("ObjectId(") {
|
||||
Some("ObjectId(")
|
||||
} else if rest.starts_with("ISODate(") {
|
||||
Some("ISODate(")
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let Some(constructor) = constructor else {
|
||||
let ch = rest.chars().next().ok_or("Invalid MongoDB argument.")?;
|
||||
output.push(ch);
|
||||
index += ch.len_utf8();
|
||||
continue;
|
||||
};
|
||||
let open = index + constructor.len() - 1;
|
||||
let close = matching_paren(input, open).ok_or("Unclosed MongoDB value constructor.")?;
|
||||
let inner = input[open + 1..close].trim();
|
||||
let value = parse_string_arg(inner)?;
|
||||
let key = if constructor.starts_with("ObjectId") { "$oid" } else { "$date" };
|
||||
output.push_str(&format!("{{\"{key}\":{}}}", serde_json::to_string(&value).unwrap()));
|
||||
index = close + 1;
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn parse_json_value(value: &str) -> Option<Value> {
|
||||
serde_json::from_str(value).ok()
|
||||
}
|
||||
|
||||
fn is_empty_object(value: &str) -> bool {
|
||||
parse_json_value(value).is_some_and(|value| value.as_object().is_some_and(|object| object.is_empty()))
|
||||
}
|
||||
|
||||
fn aggregate_writes(pipeline: &str) -> bool {
|
||||
parse_json_value(pipeline).is_some_and(|value| {
|
||||
value.as_array().is_some_and(|stages| {
|
||||
stages.iter().any(|stage| {
|
||||
stage
|
||||
.as_object()
|
||||
.is_some_and(|object| object.keys().any(|key| matches!(key.as_str(), "$out" | "$merge")))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn matching_paren(source: &str, open: usize) -> Option<usize> {
|
||||
let bytes = source.as_bytes();
|
||||
let mut depth = 0;
|
||||
let mut quote = None;
|
||||
let mut escape = false;
|
||||
for (index, byte) in bytes.iter().enumerate().skip(open) {
|
||||
let ch = *byte as char;
|
||||
if escape {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if quote.is_some() {
|
||||
if ch == '\\' {
|
||||
escape = true;
|
||||
} else if Some(ch) == quote {
|
||||
quote = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ch == '\'' || ch == '"' || ch == '`' {
|
||||
quote = Some(ch);
|
||||
} else if ch == '(' {
|
||||
depth += 1;
|
||||
} else if ch == ')' {
|
||||
depth -= 1;
|
||||
if depth == 0 {
|
||||
return Some(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn split_top_level(source: &str) -> Vec<String> {
|
||||
if source.trim().is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut result = Vec::new();
|
||||
let mut start = 0;
|
||||
let mut depth = 0;
|
||||
let mut quote = None;
|
||||
let mut escape = false;
|
||||
for (index, byte) in source.as_bytes().iter().enumerate() {
|
||||
let ch = *byte as char;
|
||||
if escape {
|
||||
escape = false;
|
||||
continue;
|
||||
}
|
||||
if quote.is_some() {
|
||||
if ch == '\\' {
|
||||
escape = true;
|
||||
} else if Some(ch) == quote {
|
||||
quote = None;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if ch == '\'' || ch == '"' || ch == '`' {
|
||||
quote = Some(ch);
|
||||
} else if matches!(ch, '(' | '[' | '{') {
|
||||
depth += 1;
|
||||
} else if matches!(ch, ')' | ']' | '}') {
|
||||
depth -= 1;
|
||||
} else if ch == ',' && depth == 0 {
|
||||
result.push(source[start..index].trim().to_string());
|
||||
start = index + 1;
|
||||
}
|
||||
}
|
||||
result.push(source[start..].trim().to_string());
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_find_with_compass_syntax_and_chain() {
|
||||
assert_eq!(
|
||||
parse("db.products.find({_id: ObjectId('507f1f77bcf86cd799439011')}, {title: 1, _id: 0}).sort({title: 1}).limit(1)").unwrap(),
|
||||
MongoCommand::Find {
|
||||
collection: "products".to_string(),
|
||||
filter: r#"{"_id":{"$oid":"507f1f77bcf86cd799439011"}}"#.to_string(),
|
||||
projection: Some(r#"{"title":1,"_id":0}"#.to_string()),
|
||||
sort: Some(r#"{"title":1}"#.to_string()),
|
||||
skip: 0,
|
||||
limit: 1,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_get_collection_and_count() {
|
||||
assert_eq!(
|
||||
parse("db.getCollection('audit.logs').count()").unwrap(),
|
||||
MongoCommand::Count { collection: "audit.logs".to_string(), filter: "{}".to_string(), accurate: false }
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn identifies_dangerous_aggregate_and_empty_writes() {
|
||||
let aggregate = parse(r#"db.projects.aggregate([{"$out":"backup"}])"#).unwrap();
|
||||
assert!(aggregate.is_mutating());
|
||||
assert!(aggregate.is_dangerous());
|
||||
let update = parse("db.projects.updateMany({}, {$set: {active: false}})").unwrap();
|
||||
assert!(update.has_empty_filter());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_multiline_chains_and_update_options() {
|
||||
let command = parse(
|
||||
r#"db.getCollection("operation_logs")
|
||||
.find({_id: ObjectId("68ad51ca84c8127bc7d44cb3")})
|
||||
.sort({ts: -1})
|
||||
.skip(5)
|
||||
.limit(10)"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(command, MongoCommand::Find { skip: 5, limit: 10, .. }));
|
||||
|
||||
let update = parse(
|
||||
r#"db.orders.updateMany({status: "open"}, {$set: {"items.$[item].status": "done"}}, {arrayFilters: [{"item.id": 7}]})"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(matches!(update, MongoCommand::Update { many: true, options: Some(_), .. }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepts_stats_and_rejects_negative_pagination() {
|
||||
assert!(matches!(
|
||||
parse("db.users.stats(1024)").unwrap(),
|
||||
MongoCommand::CollectionStats { metric, scale: Some(_), .. } if metric == "stats"
|
||||
));
|
||||
assert!(parse("db.users.find({}).skip(-1)").is_err());
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,46 @@
|
|||
use std::path::PathBuf;
|
||||
|
||||
pub const STORAGE_DB_FILE_NAME: &str = "dbx.db";
|
||||
|
||||
pub fn app_data_dir() -> Result<PathBuf, String> {
|
||||
if let Some(path) = std::env::var_os("DBX_DATA_DIR").filter(|value| !value.is_empty()) {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
|
||||
let home = std::env::var_os("HOME")
|
||||
.or_else(|| std::env::var_os("USERPROFILE"))
|
||||
.map(PathBuf::from)
|
||||
.ok_or_else(|| "Unable to resolve the user home directory. Set DBX_DATA_DIR explicitly.".to_string())?;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
return Ok(home.join("Library/Application Support/com.dbx.app"));
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
return Ok(std::env::var_os("APPDATA")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| home.join("AppData/Roaming"))
|
||||
.join("com.dbx.app"));
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
return Ok(home.join(".local/share/com.dbx.app"));
|
||||
}
|
||||
|
||||
pub fn storage_db_path() -> Result<PathBuf, String> {
|
||||
Ok(app_data_dir()?.join(STORAGE_DB_FILE_NAME))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn explicit_data_dir_wins() {
|
||||
let original = std::env::var_os("DBX_DATA_DIR");
|
||||
std::env::set_var("DBX_DATA_DIR", "/tmp/dbx-mcp-data");
|
||||
assert_eq!(app_data_dir().unwrap(), PathBuf::from("/tmp/dbx-mcp-data"));
|
||||
match original {
|
||||
Some(value) => std::env::set_var("DBX_DATA_DIR", value),
|
||||
None => std::env::remove_var("DBX_DATA_DIR"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,898 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use rmcp::{
|
||||
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
|
||||
model::{CallToolResult, ContentBlock, Implementation, ServerCapabilities, ServerInfo},
|
||||
schemars, tool, tool_handler, tool_router, ServerHandler,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::backend::{new_connection_config, parse_database_type, ConnectionSummary, DbxBackend};
|
||||
use crate::mongo::{self, MongoCommand};
|
||||
use dbx_core::{
|
||||
db::redis_driver::{classify_command, parse_command_argv, RedisCommandResult, RedisCommandSafety},
|
||||
models::connection::DatabaseType,
|
||||
production_safety::{is_production_database, targets_production_database},
|
||||
sql_risk::{classify_sql_risk_for_database, SqlRisk},
|
||||
};
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct ListConnectionsRequest {}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct ConnectionSelector {
|
||||
#[schemars(description = "Unique ID of the DBX connection")]
|
||||
pub connection_id: Option<String>,
|
||||
#[schemars(description = "Name of the DBX connection")]
|
||||
pub connection_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct ListTablesRequest {
|
||||
#[serde(flatten)]
|
||||
pub selector: ConnectionSelector,
|
||||
#[schemars(description = "Database name")]
|
||||
pub database: Option<String>,
|
||||
#[schemars(description = "Schema name")]
|
||||
pub schema: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct DescribeTableRequest {
|
||||
#[serde(flatten)]
|
||||
pub selector: ConnectionSelector,
|
||||
#[schemars(description = "Table name")]
|
||||
pub table: String,
|
||||
#[schemars(description = "Database name")]
|
||||
pub database: Option<String>,
|
||||
#[schemars(description = "Schema name")]
|
||||
pub schema: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct ExecuteQueryRequest {
|
||||
#[serde(flatten)]
|
||||
pub selector: ConnectionSelector,
|
||||
#[schemars(description = "Database name")]
|
||||
pub database: Option<String>,
|
||||
#[schemars(description = "SQL query to execute")]
|
||||
pub sql: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct AddConnectionRequest {
|
||||
pub name: String,
|
||||
pub db_type: String,
|
||||
pub host: String,
|
||||
pub port: Option<u16>,
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
#[serde(default)]
|
||||
pub password: String,
|
||||
pub database: Option<String>,
|
||||
#[serde(default)]
|
||||
pub ssl: bool,
|
||||
pub driver_profile: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct RemoveConnectionRequest {
|
||||
pub connection_name: String,
|
||||
pub connection_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct ExecuteRedisCommandRequest {
|
||||
#[serde(flatten)]
|
||||
pub selector: ConnectionSelector,
|
||||
#[schemars(description = "Redis logical database number")]
|
||||
pub db: Option<u32>,
|
||||
#[schemars(description = "Redis command to execute, for example GET mykey or INFO")]
|
||||
pub command: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct SchemaContextRequest {
|
||||
#[serde(flatten)]
|
||||
pub selector: ConnectionSelector,
|
||||
pub database: Option<String>,
|
||||
pub schema: Option<String>,
|
||||
#[schemars(description = "Specific table names to include")]
|
||||
pub tables: Option<Vec<String>>,
|
||||
#[schemars(description = "Maximum number of tables to include, from 1 to 20")]
|
||||
pub max_tables: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct OpenTableRequest {
|
||||
#[serde(flatten)]
|
||||
pub selector: ConnectionSelector,
|
||||
pub table: String,
|
||||
pub database: Option<String>,
|
||||
pub schema: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct ExecuteAndShowRequest {
|
||||
#[serde(flatten)]
|
||||
pub selector: ConnectionSelector,
|
||||
pub sql: String,
|
||||
pub database: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct DbxMcpServer {
|
||||
backend: Arc<dyn DbxBackend>,
|
||||
scope: McpScope,
|
||||
tool_router: ToolRouter<Self>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct McpScope {
|
||||
pub connection_id: Option<String>,
|
||||
pub connection_name: Option<String>,
|
||||
pub database: Option<String>,
|
||||
}
|
||||
|
||||
impl McpScope {
|
||||
pub fn from_env() -> Self {
|
||||
Self {
|
||||
connection_id: non_empty_env("DBX_MCP_SCOPE_CONNECTION_ID"),
|
||||
connection_name: non_empty_env("DBX_MCP_SCOPE_CONNECTION_NAME"),
|
||||
database: non_empty_env("DBX_MCP_SCOPE_DATABASE"),
|
||||
}
|
||||
}
|
||||
|
||||
fn enabled(&self) -> bool {
|
||||
self.connection_id.is_some() || self.connection_name.is_some()
|
||||
}
|
||||
|
||||
fn matches(&self, connection: &dbx_core::models::connection::ConnectionConfig) -> bool {
|
||||
self.connection_id.as_deref() == Some(connection.id.as_str())
|
||||
|| self.connection_name.as_deref() == Some(connection.name.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
impl DbxMcpServer {
|
||||
pub fn new(backend: Arc<dyn DbxBackend>) -> Self {
|
||||
Self::with_runtime_options(backend, McpScope::from_env(), std::env::var_os("DBX_WEB_URL").is_some())
|
||||
}
|
||||
|
||||
pub fn with_runtime_options(backend: Arc<dyn DbxBackend>, scope: McpScope, web_mode: bool) -> Self {
|
||||
let mut tool_router = Self::tool_router();
|
||||
if scope.enabled() {
|
||||
tool_router.disable_route("dbx_add_connection");
|
||||
tool_router.disable_route("dbx_remove_connection");
|
||||
}
|
||||
// Desktop UI bridge operations are intentionally unavailable remotely and in scoped AI sessions.
|
||||
if web_mode || scope.enabled() {
|
||||
tool_router.disable_route("dbx_open_table");
|
||||
tool_router.disable_route("dbx_execute_and_show");
|
||||
}
|
||||
Self { backend, scope, tool_router }
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_router]
|
||||
impl DbxMcpServer {
|
||||
#[tool(
|
||||
name = "dbx_list_connections",
|
||||
description = "List database connections configured in DBX. Returns connection IDs, names, database types, endpoints, and selected databases."
|
||||
)]
|
||||
async fn list_connections(
|
||||
&self,
|
||||
Parameters(ListConnectionsRequest {}): Parameters<ListConnectionsRequest>,
|
||||
) -> CallToolResult {
|
||||
match self.load_scoped_connections().await {
|
||||
Ok(connections) if connections.is_empty() => text("No connections configured in DBX."),
|
||||
Ok(connections) => {
|
||||
let rows = connections.iter().map(ConnectionSummary::from).collect::<Vec<_>>();
|
||||
text(format_connections(&rows))
|
||||
}
|
||||
Err(error) => tool_error("CONNECTION_LOAD_ERROR", error),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(name = "dbx_list_tables", description = "List tables and views for a database connection")]
|
||||
async fn list_tables(&self, Parameters(request): Parameters<ListTablesRequest>) -> CallToolResult {
|
||||
let connection = match self.resolve_connection(&request.selector).await {
|
||||
Ok(connection) => connection,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let database = self.resolve_database(request.database, &connection);
|
||||
match self.backend.list_tables(&connection, &database, &request.schema.unwrap_or_default()).await {
|
||||
Ok(tables) if tables.is_empty() => text("No tables found."),
|
||||
Ok(tables) => text(
|
||||
tables
|
||||
.into_iter()
|
||||
.map(|table| {
|
||||
let comment = table
|
||||
.comment
|
||||
.filter(|comment| !comment.is_empty())
|
||||
.map(|comment| format!(" -- {comment}"))
|
||||
.unwrap_or_default();
|
||||
format!("- {} ({}){}", table.name, table.table_type, comment)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
),
|
||||
Err(error) => tool_error("TABLE_LIST_ERROR", error),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(name = "dbx_describe_table", description = "Get column definitions for a table")]
|
||||
async fn describe_table(&self, Parameters(request): Parameters<DescribeTableRequest>) -> CallToolResult {
|
||||
let connection = match self.resolve_connection(&request.selector).await {
|
||||
Ok(connection) => connection,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let database = self.resolve_database(request.database, &connection);
|
||||
match self
|
||||
.backend
|
||||
.get_columns(&connection, &database, &request.schema.unwrap_or_default(), &request.table)
|
||||
.await
|
||||
{
|
||||
Ok(columns) if columns.is_empty() => text("No columns found."),
|
||||
Ok(columns) => text(format_columns(&columns)),
|
||||
Err(error) => tool_error("TABLE_DESCRIPTION_ERROR", error),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "dbx_execute_query",
|
||||
description = "Execute a SQL query on a database connection (max 100 rows returned)"
|
||||
)]
|
||||
async fn execute_query(&self, Parameters(request): Parameters<ExecuteQueryRequest>) -> CallToolResult {
|
||||
let connection = match self.resolve_connection(&request.selector).await {
|
||||
Ok(connection) => connection,
|
||||
Err(error) => return error,
|
||||
};
|
||||
if connection.db_type == dbx_core::models::connection::DatabaseType::Redis {
|
||||
return tool_error(
|
||||
"REDIS_COMMAND_REQUIRED",
|
||||
"Redis connections do not accept SQL through dbx_execute_query. Use dbx_execute_redis_command.",
|
||||
);
|
||||
}
|
||||
let database = self.resolve_database(request.database, &connection);
|
||||
if connection.db_type == DatabaseType::MongoDb {
|
||||
let command = match validate_mongo_command(&connection, &database, &request.sql) {
|
||||
Ok(command) => command,
|
||||
Err(error) => return error,
|
||||
};
|
||||
return match self.backend.execute_mongo_command(&connection, &database, &command).await {
|
||||
Ok(result) => text(result),
|
||||
Err(error) => tool_error("QUERY_ERROR", error),
|
||||
};
|
||||
}
|
||||
let result = self
|
||||
.backend
|
||||
.execute_agent_tool(
|
||||
&connection,
|
||||
&database,
|
||||
"execute_query",
|
||||
json!({ "sql": request.sql, "limit": 100 }),
|
||||
default_permissions(),
|
||||
)
|
||||
.await;
|
||||
agent_result(result)
|
||||
}
|
||||
|
||||
#[tool(name = "dbx_execute_redis_command", description = "Execute a Redis command on a Redis connection")]
|
||||
async fn execute_redis_command(
|
||||
&self,
|
||||
Parameters(request): Parameters<ExecuteRedisCommandRequest>,
|
||||
) -> CallToolResult {
|
||||
let connection = match self.resolve_connection(&request.selector).await {
|
||||
Ok(connection) => connection,
|
||||
Err(error) => return error,
|
||||
};
|
||||
if connection.db_type != DatabaseType::Redis {
|
||||
return tool_error("INVALID_CONNECTION_TYPE", format!("Connection \"{}\" is not Redis.", connection.name));
|
||||
}
|
||||
let argv = match parse_command_argv(&request.command) {
|
||||
Ok(argv) => argv,
|
||||
Err(error) => return tool_error("REDIS_COMMAND_BLOCKED", error),
|
||||
};
|
||||
let safety = classify_command(&argv[0]);
|
||||
let permissions = default_permissions();
|
||||
if safety == RedisCommandSafety::Blocked && !permissions.allow_dangerous {
|
||||
return tool_error(
|
||||
"REDIS_COMMAND_BLOCKED",
|
||||
format!(
|
||||
"Dangerous Redis command \"{}\" is blocked. Set DBX_MCP_ALLOW_DANGEROUS_SQL=1 to allow it.",
|
||||
argv[0].to_ascii_uppercase()
|
||||
),
|
||||
);
|
||||
}
|
||||
if safety != RedisCommandSafety::Allowed && !permissions.allow_writes {
|
||||
return tool_error(
|
||||
"REDIS_COMMAND_BLOCKED",
|
||||
"MCP Redis command execution is read-only for this session. Set DBX_MCP_ALLOW_WRITES=1 to allow write or dangerous commands.",
|
||||
);
|
||||
}
|
||||
let database = request
|
||||
.db
|
||||
.or_else(|| self.scope.database.as_deref().and_then(parse_redis_database))
|
||||
.or_else(|| redis_database(&connection))
|
||||
.unwrap_or(0);
|
||||
// Production protection is stricter than the opt-in write flags by design.
|
||||
if safety != RedisCommandSafety::Allowed && is_production_database(&connection, &database.to_string()) {
|
||||
return tool_error(
|
||||
"PRODUCTION_WRITE_BLOCKED",
|
||||
"MCP cannot execute write or dangerous Redis commands against a production database.",
|
||||
);
|
||||
}
|
||||
match self
|
||||
.backend
|
||||
.execute_redis_command(
|
||||
&connection,
|
||||
database,
|
||||
&request.command,
|
||||
safety == RedisCommandSafety::Blocked && permissions.allow_dangerous,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(result) => text(format_redis_result(&result)),
|
||||
Err(error) => tool_error("REDIS_COMMAND_ERROR", error),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(name = "dbx_get_schema_context", description = "Get compact table and column context for writing SQL")]
|
||||
async fn get_schema_context(&self, Parameters(request): Parameters<SchemaContextRequest>) -> CallToolResult {
|
||||
let connection = match self.resolve_connection(&request.selector).await {
|
||||
Ok(connection) => connection,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let database = self.resolve_database(request.database, &connection);
|
||||
let schema = request.schema.unwrap_or_default();
|
||||
let max_tables = request.max_tables.unwrap_or(8).clamp(1, 20);
|
||||
let available = match self.backend.list_tables(&connection, &database, &schema).await {
|
||||
Ok(tables) => tables,
|
||||
Err(error) => return tool_error("SCHEMA_CONTEXT_ERROR", error),
|
||||
};
|
||||
let requested = request
|
||||
.tables
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|name| name.to_ascii_lowercase())
|
||||
.collect::<std::collections::HashSet<_>>();
|
||||
let mut selected = if requested.is_empty() {
|
||||
available.iter().collect::<Vec<_>>()
|
||||
} else {
|
||||
available.iter().filter(|table| requested.contains(&table.name.to_ascii_lowercase())).collect::<Vec<_>>()
|
||||
};
|
||||
let truncated = selected.len() > max_tables || (requested.is_empty() && available.len() > max_tables);
|
||||
selected.truncate(max_tables);
|
||||
if selected.is_empty() {
|
||||
return text("No matching tables found.");
|
||||
}
|
||||
let mut tables = Vec::with_capacity(selected.len());
|
||||
for table in selected {
|
||||
// Keep metadata calls sequential because some embedded drivers expose a single physical connection.
|
||||
let columns = match self.backend.get_columns(&connection, &database, &schema, &table.name).await {
|
||||
Ok(columns) => columns,
|
||||
Err(error) => return tool_error("SCHEMA_CONTEXT_ERROR", error),
|
||||
};
|
||||
tables.push((table.clone(), columns));
|
||||
}
|
||||
text(format_schema_context(&connection.name, &database, &schema, &tables, truncated))
|
||||
}
|
||||
|
||||
#[tool(name = "dbx_add_connection", description = "Add a new database connection to DBX")]
|
||||
async fn add_connection(&self, Parameters(request): Parameters<AddConnectionRequest>) -> CallToolResult {
|
||||
let mut connections = match self.backend.load_connections().await {
|
||||
Ok(connections) => connections,
|
||||
Err(error) => return tool_error("CONNECTION_LOAD_ERROR", error),
|
||||
};
|
||||
if connections.iter().any(|connection| connection.name.eq_ignore_ascii_case(&request.name)) {
|
||||
return text(format!("Connection \"{}\" already exists.", request.name));
|
||||
}
|
||||
let db_type = match parse_database_type(&request.db_type) {
|
||||
Ok(db_type) => db_type,
|
||||
Err(error) => return tool_error("INVALID_CONNECTION_TYPE", error),
|
||||
};
|
||||
let port = match request.port.or_else(|| default_port(&request.db_type)) {
|
||||
Some(port) => port,
|
||||
None => return text("Port is required for this database type."),
|
||||
};
|
||||
let config = match new_connection_config(
|
||||
Uuid::new_v4().to_string(),
|
||||
request.name,
|
||||
db_type,
|
||||
request.host,
|
||||
port,
|
||||
request.username,
|
||||
request.password,
|
||||
request.database,
|
||||
request.ssl,
|
||||
request.driver_profile,
|
||||
) {
|
||||
Ok(config) => config,
|
||||
Err(error) => return tool_error("INVALID_CONNECTION", error),
|
||||
};
|
||||
connections.push(config.clone());
|
||||
if let Err(error) = self.backend.save_connections(&connections).await {
|
||||
return tool_error("CONNECTION_SAVE_ERROR", error);
|
||||
}
|
||||
text(format!("Connection \"{}\" added (id: {}).", config.name, config.id))
|
||||
}
|
||||
|
||||
#[tool(name = "dbx_remove_connection", description = "Remove a database connection from DBX")]
|
||||
async fn remove_connection(&self, Parameters(request): Parameters<RemoveConnectionRequest>) -> CallToolResult {
|
||||
let mut connections = match self.backend.load_connections().await {
|
||||
Ok(connections) => connections,
|
||||
Err(error) => return tool_error("CONNECTION_LOAD_ERROR", error),
|
||||
};
|
||||
let target = if let Some(id) = request.connection_id.as_deref().map(str::trim).filter(|id| !id.is_empty()) {
|
||||
connections.iter().find(|connection| connection.id == id).cloned()
|
||||
} else {
|
||||
let matching = connections
|
||||
.iter()
|
||||
.filter(|connection| connection.name.eq_ignore_ascii_case(&request.connection_name))
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
if matching.len() > 1 {
|
||||
return tool_error("AMBIGUOUS_CONNECTION", ambiguous_connections(&request.connection_name, &matching));
|
||||
}
|
||||
matching.into_iter().next()
|
||||
};
|
||||
let Some(target) = target else {
|
||||
return tool_error(
|
||||
"CONNECTION_NOT_FOUND",
|
||||
format!("Connection \"{}\" not found.", request.connection_name),
|
||||
);
|
||||
};
|
||||
connections.retain(|connection| connection.id != target.id);
|
||||
if let Err(error) = self.backend.save_connections(&connections).await {
|
||||
return tool_error("CONNECTION_SAVE_ERROR", error);
|
||||
}
|
||||
text(format!("Connection \"{}\" (id: {}) removed.", target.name, target.id))
|
||||
}
|
||||
|
||||
#[tool(name = "dbx_open_table", description = "Open a table in DBX desktop app. Requires DBX to be running.")]
|
||||
async fn open_table(&self, Parameters(request): Parameters<OpenTableRequest>) -> CallToolResult {
|
||||
let connection = match self.resolve_connection(&request.selector).await {
|
||||
Ok(connection) => connection,
|
||||
Err(error) => return error,
|
||||
};
|
||||
let database = self.resolve_database(request.database, &connection);
|
||||
match self
|
||||
.backend
|
||||
.bridge_request(
|
||||
"/open-table",
|
||||
json!({
|
||||
"connection_id": connection.id,
|
||||
"connection_name": connection.name,
|
||||
"table": request.table,
|
||||
"database": database,
|
||||
"schema": request.schema,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => text(format!("Opened {} in DBX", request.table)),
|
||||
Err(error) => tool_error("DBX_NOT_RUNNING", error),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "dbx_execute_and_show",
|
||||
description = "Execute a SQL query in DBX desktop app UI and show results there. Requires DBX to be running."
|
||||
)]
|
||||
async fn execute_and_show(&self, Parameters(request): Parameters<ExecuteAndShowRequest>) -> CallToolResult {
|
||||
let connection = match self.resolve_connection(&request.selector).await {
|
||||
Ok(connection) => connection,
|
||||
Err(error) => return error,
|
||||
};
|
||||
if connection.db_type == DatabaseType::Redis {
|
||||
return tool_error("REDIS_COMMAND_REQUIRED", "Use dbx_execute_redis_command for Redis connections.");
|
||||
}
|
||||
let database = self.resolve_database(request.database, &connection);
|
||||
let permissions = default_permissions();
|
||||
if connection.db_type == DatabaseType::MongoDb {
|
||||
if let Err(error) = validate_mongo_command(&connection, &database, &request.sql) {
|
||||
return error;
|
||||
}
|
||||
} else {
|
||||
let risk = match classify_sql_risk_for_database(&request.sql, connection.db_type) {
|
||||
Ok(risk) => risk,
|
||||
Err(error) => return tool_error("SQL_BLOCKED", error),
|
||||
};
|
||||
if risk != SqlRisk::ReadOnly && targets_production_database(&connection, &database, &request.sql) {
|
||||
return tool_error(
|
||||
"PRODUCTION_WRITE_BLOCKED",
|
||||
"MCP cannot send writes against a production database to DBX.",
|
||||
);
|
||||
}
|
||||
if risk == SqlRisk::Transaction || (risk == SqlRisk::Ddl && !permissions.allow_dangerous) {
|
||||
return tool_error("SQL_BLOCKED", format!("{} statement is blocked for this session.", risk));
|
||||
}
|
||||
if risk == SqlRisk::Write && !permissions.allow_writes {
|
||||
return tool_error("SQL_BLOCKED", "MCP SQL execution is read-only for this session.");
|
||||
}
|
||||
}
|
||||
match self
|
||||
.backend
|
||||
.bridge_request(
|
||||
"/execute-query",
|
||||
json!({
|
||||
"connection_id": connection.id,
|
||||
"connection_name": connection.name,
|
||||
"sql": request.sql,
|
||||
"database": database,
|
||||
"allow_writes": permissions.allow_writes,
|
||||
"allow_dangerous": permissions.allow_dangerous,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => text("Query sent to DBX"),
|
||||
Err(error) => tool_error("DBX_NOT_RUNNING", error),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl DbxMcpServer {
|
||||
async fn load_scoped_connections(&self) -> Result<Vec<dbx_core::models::connection::ConnectionConfig>, String> {
|
||||
let connections = self.backend.load_connections().await?;
|
||||
if !self.scope.enabled() {
|
||||
return Ok(connections);
|
||||
}
|
||||
Ok(connections.into_iter().filter(|connection| self.scope.matches(connection)).collect())
|
||||
}
|
||||
|
||||
fn resolve_database(
|
||||
&self,
|
||||
requested: Option<String>,
|
||||
connection: &dbx_core::models::connection::ConnectionConfig,
|
||||
) -> String {
|
||||
requested.or_else(|| self.scope.database.clone()).or_else(|| connection.database.clone()).unwrap_or_default()
|
||||
}
|
||||
|
||||
async fn resolve_connection(
|
||||
&self,
|
||||
selector: &ConnectionSelector,
|
||||
) -> Result<dbx_core::models::connection::ConnectionConfig, CallToolResult> {
|
||||
let connections =
|
||||
self.backend.load_connections().await.map_err(|error| tool_error("CONNECTION_LOAD_ERROR", error))?;
|
||||
if let Some(id) = selector.connection_id.as_deref().map(str::trim).filter(|id| !id.is_empty()) {
|
||||
let connection = connections
|
||||
.into_iter()
|
||||
.find(|connection| connection.id == id)
|
||||
.ok_or_else(|| tool_error("CONNECTION_NOT_FOUND", format!("Connection with id \"{id}\" not found.")))?;
|
||||
if self.scope.enabled() && !self.scope.matches(&connection) {
|
||||
return Err(tool_error(
|
||||
"CONNECTION_OUT_OF_SCOPE",
|
||||
format!("Connection \"{id}\" is outside this DBX AI session scope."),
|
||||
));
|
||||
}
|
||||
return Ok(connection);
|
||||
}
|
||||
if self.scope.enabled() {
|
||||
let connection = connections
|
||||
.into_iter()
|
||||
.find(|connection| self.scope.matches(connection))
|
||||
.ok_or_else(|| tool_error("CONNECTION_NOT_FOUND", "Scoped DBX connection was not found."))?;
|
||||
if let Some(name) = selector.connection_name.as_deref().map(str::trim).filter(|name| !name.is_empty()) {
|
||||
if name != connection.name && name != connection.id {
|
||||
return Err(tool_error(
|
||||
"CONNECTION_OUT_OF_SCOPE",
|
||||
format!("Connection \"{name}\" is outside this DBX AI session scope."),
|
||||
));
|
||||
}
|
||||
}
|
||||
return Ok(connection);
|
||||
}
|
||||
let Some(name) = selector.connection_name.as_deref().map(str::trim).filter(|name| !name.is_empty()) else {
|
||||
return Err(tool_error("CONNECTION_NOT_FOUND", "Either connection_id or connection_name is required."));
|
||||
};
|
||||
let matching =
|
||||
connections.into_iter().filter(|connection| connection.name.eq_ignore_ascii_case(name)).collect::<Vec<_>>();
|
||||
match matching.as_slice() {
|
||||
[] => Err(tool_error("CONNECTION_NOT_FOUND", format!("Connection \"{name}\" not found."))),
|
||||
[connection] => Ok(connection.clone()),
|
||||
_ => Err(tool_error("AMBIGUOUS_CONNECTION", ambiguous_connections(name, &matching))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler(router = self.tool_router)]
|
||||
impl ServerHandler for DbxMcpServer {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
|
||||
.with_server_info(Implementation::new("dbx", env!("CARGO_PKG_VERSION")))
|
||||
.with_instructions("Use DBX connections to inspect schemas and query databases safely.")
|
||||
}
|
||||
}
|
||||
|
||||
fn text(value: impl Into<String>) -> CallToolResult {
|
||||
CallToolResult::success(vec![ContentBlock::text(value)])
|
||||
}
|
||||
|
||||
fn tool_error(code: &str, message: impl Into<String>) -> CallToolResult {
|
||||
CallToolResult::error(vec![ContentBlock::text(format!("Error [{code}]: {}", message.into()))])
|
||||
}
|
||||
|
||||
fn agent_result(result: dbx_core::agent_events::ToolResult) -> CallToolResult {
|
||||
if result.is_error {
|
||||
tool_error("DBX_TOOL_ERROR", result.content.trim_start_matches("Error: "))
|
||||
} else {
|
||||
text(result.content)
|
||||
}
|
||||
}
|
||||
|
||||
fn default_permissions() -> dbx_core::agent_tools::AgentSqlPermissions {
|
||||
dbx_core::agent_tools::AgentSqlPermissions {
|
||||
allow_writes: boolean_env("DBX_MCP_ALLOW_WRITES").unwrap_or(true),
|
||||
allow_dangerous: boolean_env("DBX_MCP_ALLOW_DANGEROUS_SQL").unwrap_or(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_mongo_command(
|
||||
connection: &dbx_core::models::connection::ConnectionConfig,
|
||||
database: &str,
|
||||
source: &str,
|
||||
) -> Result<MongoCommand, CallToolResult> {
|
||||
let command = mongo::parse(source).map_err(|error| {
|
||||
tool_error(
|
||||
"QUERY_ERROR",
|
||||
format!(
|
||||
"{error} Use MongoDB shell-style commands such as db.collection.find({{}}), db.collection.aggregate([]), or db.collection.countDocuments({{}})."
|
||||
),
|
||||
)
|
||||
})?;
|
||||
let permissions = default_permissions();
|
||||
if command.is_mutating() && !permissions.allow_writes {
|
||||
return Err(tool_error(
|
||||
"SQL_BLOCKED",
|
||||
"MCP MongoDB execution is read-only for this session. Set DBX_MCP_ALLOW_WRITES=1 to allow write commands.",
|
||||
));
|
||||
}
|
||||
if command.has_empty_filter() && !permissions.allow_dangerous {
|
||||
return Err(tool_error(
|
||||
"SQL_BLOCKED",
|
||||
"MongoDB update/delete commands must include a non-empty filter unless DBX_MCP_ALLOW_DANGEROUS_SQL=1 is set.",
|
||||
));
|
||||
}
|
||||
if command.is_dangerous() && !permissions.allow_dangerous {
|
||||
return Err(tool_error(
|
||||
"SQL_BLOCKED",
|
||||
"Dangerous MongoDB command is blocked. Set DBX_MCP_ALLOW_DANGEROUS_SQL=1 to allow it.",
|
||||
));
|
||||
}
|
||||
if command.is_mutating() && is_production_database(connection, database) {
|
||||
return Err(tool_error("PRODUCTION_WRITE_BLOCKED", "MCP cannot execute writes against a production database."));
|
||||
}
|
||||
Ok(command)
|
||||
}
|
||||
|
||||
fn boolean_env(name: &str) -> Option<bool> {
|
||||
match std::env::var(name).ok()?.trim().to_ascii_lowercase().as_str() {
|
||||
"1" | "true" => Some(true),
|
||||
"0" | "false" => Some(false),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_env(name: &str) -> Option<String> {
|
||||
std::env::var(name).ok().map(|value| value.trim().to_string()).filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
fn default_port(db_type: &str) -> Option<u16> {
|
||||
match db_type.trim().to_ascii_lowercase().as_str() {
|
||||
"mysql" | "doris" | "starrocks" | "manticoresearch" => Some(3306),
|
||||
"postgres" | "redshift" | "highgo" | "kingbase" | "opengauss" | "gaussdb" => Some(5432),
|
||||
"redis" => Some(6379),
|
||||
"mongodb" => Some(27017),
|
||||
"rqlite" => Some(4001),
|
||||
"kwdb" => Some(26257),
|
||||
"cloudflare-d1" => Some(443),
|
||||
"tdengine" => Some(6041),
|
||||
"iotdb" => Some(6667),
|
||||
"xugu" => Some(5138),
|
||||
"sqlite" | "duckdb" | "access" => Some(0),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn ambiguous_connections(name: &str, connections: &[dbx_core::models::connection::ConnectionConfig]) -> String {
|
||||
let lines = connections
|
||||
.iter()
|
||||
.map(|connection| {
|
||||
format!("- {}: {:?} @ {}:{}", connection.id, connection.db_type, connection.host, connection.port)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
format!("Multiple connections found with name \"{name}\". Please specify connection_id:\n{lines}")
|
||||
}
|
||||
|
||||
fn format_connections(connections: &[ConnectionSummary]) -> String {
|
||||
let mut output =
|
||||
String::from("| ID | Name | Type | Host | Port | Database |\n| --- | --- | --- | --- | --- | --- |");
|
||||
for connection in connections {
|
||||
output.push_str(&format!(
|
||||
"\n| {} | {} | {} | {} | {} | {} |",
|
||||
escape_cell(&connection.id),
|
||||
escape_cell(&connection.name),
|
||||
escape_cell(&connection.db_type),
|
||||
escape_cell(&connection.host),
|
||||
connection.port,
|
||||
escape_cell(&connection.database),
|
||||
));
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn format_columns(columns: &[dbx_core::db::ColumnInfo]) -> String {
|
||||
let rows = columns
|
||||
.iter()
|
||||
.map(|column| {
|
||||
vec![
|
||||
if column.is_primary_key { format!("{} (PK)", column.name) } else { column.name.clone() },
|
||||
column.data_type.clone(),
|
||||
if column.is_nullable { "YES".to_string() } else { "NO".to_string() },
|
||||
column.column_default.clone().unwrap_or_default(),
|
||||
column.comment.clone().unwrap_or_default(),
|
||||
]
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
markdown_table(&["Column", "Type", "Nullable", "Default", "Comment"], &rows)
|
||||
}
|
||||
|
||||
fn markdown_table(headers: &[&str], rows: &[Vec<String>]) -> String {
|
||||
let mut output = format!("| {} |\n| {} |", headers.join(" | "), vec!["---"; headers.len()].join(" | "));
|
||||
for row in rows {
|
||||
output
|
||||
.push_str(&format!("\n| {} |", row.iter().map(|value| escape_cell(value)).collect::<Vec<_>>().join(" | ")));
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
fn escape_cell(value: &str) -> String {
|
||||
value.replace('|', "\\|").replace('\n', " ")
|
||||
}
|
||||
|
||||
fn redis_database(connection: &dbx_core::models::connection::ConnectionConfig) -> Option<u32> {
|
||||
connection.database.as_deref().and_then(parse_redis_database)
|
||||
}
|
||||
|
||||
fn parse_redis_database(value: &str) -> Option<u32> {
|
||||
value.trim().parse().ok()
|
||||
}
|
||||
|
||||
fn format_redis_result(result: &RedisCommandResult) -> String {
|
||||
let value =
|
||||
result.value.as_str().map(ToOwned::to_owned).unwrap_or_else(|| {
|
||||
serde_json::to_string_pretty(&result.value).unwrap_or_else(|_| result.value.to_string())
|
||||
});
|
||||
let safety = serde_json::to_value(&result.safety)
|
||||
.ok()
|
||||
.and_then(|value| value.as_str().map(ToOwned::to_owned))
|
||||
.unwrap_or_else(|| format!("{:?}", result.safety).to_ascii_lowercase());
|
||||
format!("Command: {}\nSafety: {}\n\n{}", result.command, safety, value)
|
||||
}
|
||||
|
||||
fn format_schema_context(
|
||||
connection: &str,
|
||||
database: &str,
|
||||
schema: &str,
|
||||
tables: &[(dbx_core::db::TableInfo, Vec<dbx_core::db::ColumnInfo>)],
|
||||
truncated: bool,
|
||||
) -> String {
|
||||
let mut output = format!("Connection: {connection}");
|
||||
if !database.is_empty() {
|
||||
output.push_str(&format!("\nDatabase: {database}"));
|
||||
}
|
||||
if !schema.is_empty() {
|
||||
output.push_str(&format!("\nSchema: {schema}"));
|
||||
}
|
||||
for (table, columns) in tables {
|
||||
output.push_str(&format!("\n\n## {}\nType: {}", table.name, table.table_type));
|
||||
for column in columns {
|
||||
output.push_str(&format!(
|
||||
"\n- {} {} {}{}{}",
|
||||
column.name,
|
||||
column.data_type,
|
||||
if column.is_nullable { "NULL" } else { "NOT NULL" },
|
||||
if column.is_primary_key { " PK" } else { "" },
|
||||
column.comment.as_ref().map(|comment| format!(" -- {comment}")).unwrap_or_default(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if truncated {
|
||||
output.push_str("\n\nNote: table list was truncated; request specific table names for more context.");
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use async_trait::async_trait;
|
||||
use dbx_core::models::connection::ConnectionConfig;
|
||||
|
||||
struct FakeBackend {
|
||||
connections: Vec<ConnectionConfig>,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl DbxBackend for FakeBackend {
|
||||
async fn load_connections(&self) -> Result<Vec<ConnectionConfig>, String> {
|
||||
Ok(self.connections.clone())
|
||||
}
|
||||
|
||||
async fn execute_agent_tool(
|
||||
&self,
|
||||
_connection: &ConnectionConfig,
|
||||
_database: &str,
|
||||
tool_name: &str,
|
||||
_arguments: serde_json::Value,
|
||||
_permissions: dbx_core::agent_tools::AgentSqlPermissions,
|
||||
) -> dbx_core::agent_events::ToolResult {
|
||||
dbx_core::agent_events::ToolResult {
|
||||
tool_call_id: "test".to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
content: "ok".to_string(),
|
||||
is_error: false,
|
||||
explain_data: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_connections(&self, _connections: &[ConnectionConfig]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_table_escapes_markdown_cells() {
|
||||
let output = format_connections(&[ConnectionSummary {
|
||||
id: "id|1".to_string(),
|
||||
name: "local\npg".to_string(),
|
||||
db_type: "postgres".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 5432,
|
||||
database: "app".to_string(),
|
||||
}]);
|
||||
assert!(output.contains("id\\|1"));
|
||||
assert!(output.contains("local pg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn server_registers_list_connections_tool() {
|
||||
let server = DbxMcpServer::with_runtime_options(
|
||||
Arc::new(FakeBackend { connections: Vec::new() }),
|
||||
McpScope::default(),
|
||||
false,
|
||||
);
|
||||
let tools = server.tool_router.list_all();
|
||||
let names = tools.iter().map(|tool| tool.name.as_ref()).collect::<Vec<_>>();
|
||||
assert_eq!(tools.len(), 10);
|
||||
assert!(names.contains(&"dbx_list_connections"));
|
||||
assert!(names.contains(&"dbx_list_tables"));
|
||||
assert!(names.contains(&"dbx_describe_table"));
|
||||
assert!(names.contains(&"dbx_execute_query"));
|
||||
assert!(names.contains(&"dbx_add_connection"));
|
||||
assert!(names.contains(&"dbx_remove_connection"));
|
||||
assert!(names.contains(&"dbx_execute_redis_command"));
|
||||
assert!(names.contains(&"dbx_get_schema_context"));
|
||||
assert!(names.contains(&"dbx_open_table"));
|
||||
assert!(names.contains(&"dbx_execute_and_show"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scoped_server_hides_mutating_and_desktop_tools() {
|
||||
let server = DbxMcpServer::with_runtime_options(
|
||||
Arc::new(FakeBackend { connections: Vec::new() }),
|
||||
McpScope { connection_id: Some("scoped".to_string()), ..Default::default() },
|
||||
false,
|
||||
);
|
||||
let names = server.tool_router.list_all().into_iter().map(|tool| tool.name).collect::<Vec<_>>();
|
||||
assert_eq!(names.len(), 6);
|
||||
assert!(!names.iter().any(|name| name == "dbx_add_connection"));
|
||||
assert!(!names.iter().any(|name| name == "dbx_remove_connection"));
|
||||
assert!(!names.iter().any(|name| name == "dbx_open_table"));
|
||||
assert!(!names.iter().any(|name| name == "dbx_execute_and_show"));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use dbx_core::{models::connection::ConnectionConfig, storage::Storage};
|
||||
use dbx_mcp::{DbxMcpServer, LocalBackend, McpScope};
|
||||
use rmcp::{model::CallToolRequestParams, ServiceExt};
|
||||
use serde_json::{json, Map, Value};
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn local_backend_reads_dbx_storage_without_desktop_process() {
|
||||
let directory = tempdir().expect("temporary data directory");
|
||||
let db_path = directory.path().join("dbx.db");
|
||||
let storage = Storage::open(&db_path).await.expect("open storage");
|
||||
let connection: ConnectionConfig = serde_json::from_value(json!({
|
||||
"id": "local-sqlite",
|
||||
"name": "offline-sqlite",
|
||||
"db_type": "sqlite",
|
||||
"host": "",
|
||||
"port": 0,
|
||||
"username": "",
|
||||
"password": "",
|
||||
"database": directory.path().join("data.sqlite").to_string_lossy(),
|
||||
"ssl": false
|
||||
}))
|
||||
.expect("minimal connection config");
|
||||
storage.save_connections(&[connection]).await.expect("save connection");
|
||||
|
||||
let backend = Arc::new(LocalBackend::open(&db_path).await.expect("open local backend"));
|
||||
let server = DbxMcpServer::with_runtime_options(backend, McpScope::default(), false);
|
||||
let (server_transport, client_transport) = tokio::io::duplex(16 * 1024);
|
||||
let server_task = tokio::spawn(async move { server.serve(server_transport).await });
|
||||
let client = ().serve(client_transport).await.expect("initialize client");
|
||||
let result = client
|
||||
.peer()
|
||||
.call_tool(CallToolRequestParams::new("dbx_list_connections"))
|
||||
.await
|
||||
.expect("list local connections");
|
||||
let text = result.content[0].as_text().expect("text response");
|
||||
assert!(text.text.contains("offline-sqlite"));
|
||||
assert!(text.text.contains("local-sqlite"));
|
||||
client.cancel().await.expect("close client");
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires DBX_MCP_TEST_MONGO_HOST and DBX_MCP_TEST_MONGO_PASSWORD"]
|
||||
async fn executes_mongo_shell_commands_without_desktop_process() {
|
||||
let host = std::env::var("DBX_MCP_TEST_MONGO_HOST").expect("MongoDB host");
|
||||
let port = std::env::var("DBX_MCP_TEST_MONGO_PORT")
|
||||
.unwrap_or_else(|_| "27017".to_string())
|
||||
.parse::<u16>()
|
||||
.expect("MongoDB port");
|
||||
let password = std::env::var("DBX_MCP_TEST_MONGO_PASSWORD").expect("MongoDB password");
|
||||
let directory = tempdir().expect("temporary data directory");
|
||||
let db_path = directory.path().join("dbx.db");
|
||||
let storage = Storage::open(&db_path).await.expect("open storage");
|
||||
let connection: ConnectionConfig = serde_json::from_value(json!({
|
||||
"id": "mongo-e2e",
|
||||
"name": "mongo-e2e",
|
||||
"db_type": "mongodb",
|
||||
"host": host,
|
||||
"port": port,
|
||||
"username": "root",
|
||||
"password": password,
|
||||
"database": "dbx_mcp_test",
|
||||
"url_params": "authSource=admin",
|
||||
"ssl": false
|
||||
}))
|
||||
.expect("MongoDB connection config");
|
||||
storage.save_connections(&[connection]).await.expect("save connection");
|
||||
|
||||
let backend = Arc::new(LocalBackend::open(&db_path).await.expect("open local backend"));
|
||||
let server = DbxMcpServer::with_runtime_options(backend, McpScope::default(), false);
|
||||
let (server_transport, client_transport) = tokio::io::duplex(32 * 1024);
|
||||
let server_task = tokio::spawn(async move { server.serve(server_transport).await });
|
||||
let client = ().serve(client_transport).await.expect("initialize client");
|
||||
let original_writes = std::env::var_os("DBX_MCP_ALLOW_WRITES");
|
||||
std::env::set_var("DBX_MCP_ALLOW_WRITES", "1");
|
||||
|
||||
call_query(&client, "db.items.deleteOne({_id: 'rust-mcp-e2e'})").await;
|
||||
call_query(&client, "db.items.insertOne({_id: 'rust-mcp-e2e', name: 'Ada'})").await;
|
||||
let result = call_query(&client, "db.items.find({_id: 'rust-mcp-e2e'}).limit(1)").await;
|
||||
assert!(result.contains("Ada"), "unexpected MongoDB result: {result}");
|
||||
call_query(&client, "db.items.deleteOne({_id: 'rust-mcp-e2e'})").await;
|
||||
|
||||
match original_writes {
|
||||
Some(value) => std::env::set_var("DBX_MCP_ALLOW_WRITES", value),
|
||||
None => std::env::remove_var("DBX_MCP_ALLOW_WRITES"),
|
||||
}
|
||||
client.cancel().await.expect("close client");
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
async fn call_query(client: &rmcp::service::RunningService<rmcp::RoleClient, ()>, sql: &str) -> String {
|
||||
let arguments = json!({
|
||||
"connection_id": "mongo-e2e",
|
||||
"database": "dbx_mcp_test",
|
||||
"sql": sql,
|
||||
})
|
||||
.as_object()
|
||||
.cloned()
|
||||
.unwrap_or_else(Map::<String, Value>::new);
|
||||
let result = client
|
||||
.peer()
|
||||
.call_tool(CallToolRequestParams::new("dbx_execute_query").with_arguments(arguments))
|
||||
.await
|
||||
.expect("execute MongoDB command");
|
||||
let text = result.content[0].as_text().expect("text result").text.clone();
|
||||
assert_ne!(result.is_error, Some(true), "MongoDB command failed: {text}");
|
||||
text
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use dbx_core::{agent_events::ToolResult, agent_tools::AgentSqlPermissions, models::connection::ConnectionConfig};
|
||||
use dbx_mcp::{DbxBackend, DbxMcpServer, McpScope};
|
||||
use rmcp::{model::CallToolRequestParams, ServiceExt};
|
||||
use serde_json::Value;
|
||||
|
||||
struct EmptyBackend;
|
||||
|
||||
#[async_trait]
|
||||
impl DbxBackend for EmptyBackend {
|
||||
async fn load_connections(&self) -> Result<Vec<ConnectionConfig>, String> {
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
async fn execute_agent_tool(
|
||||
&self,
|
||||
_connection: &ConnectionConfig,
|
||||
_database: &str,
|
||||
tool_name: &str,
|
||||
_arguments: Value,
|
||||
_permissions: AgentSqlPermissions,
|
||||
) -> ToolResult {
|
||||
ToolResult {
|
||||
tool_call_id: "protocol-test".to_string(),
|
||||
tool_name: tool_name.to_string(),
|
||||
content: "ok".to_string(),
|
||||
is_error: false,
|
||||
explain_data: None,
|
||||
}
|
||||
}
|
||||
|
||||
async fn save_connections(&self, _connections: &[ConnectionConfig]) -> Result<(), String> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn initializes_lists_tools_and_calls_a_tool() {
|
||||
let (server_transport, client_transport) = tokio::io::duplex(16 * 1024);
|
||||
let server = DbxMcpServer::with_runtime_options(Arc::new(EmptyBackend), McpScope::default(), false);
|
||||
let server_task = tokio::spawn(async move { server.serve(server_transport).await });
|
||||
let client = ().serve(client_transport).await.expect("initialize MCP client");
|
||||
|
||||
let tools = client.peer().list_tools(None).await.expect("list tools");
|
||||
let names = tools.tools.iter().map(|tool| tool.name.as_ref()).collect::<Vec<_>>();
|
||||
assert_eq!(names.len(), 10);
|
||||
assert!(names.contains(&"dbx_list_connections"));
|
||||
assert!(names.contains(&"dbx_execute_redis_command"));
|
||||
assert!(names.contains(&"dbx_execute_and_show"));
|
||||
|
||||
let result = client.peer().call_tool(CallToolRequestParams::new("dbx_list_connections")).await.expect("call tool");
|
||||
let response = result.content[0].as_text().expect("text response");
|
||||
assert_eq!(response.text, "No connections configured in DBX.");
|
||||
|
||||
client.cancel().await.expect("close MCP client");
|
||||
server_task.abort();
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
# @dbx-app/mcp-darwin-arm64
|
||||
|
||||
Platform-specific Rust binary used by `@dbx-app/mcp-server`. Install the main package instead of depending on this package directly.
|
||||
Binary file not shown.
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": [
|
||||
|
|
|
|||
130
pnpm-lock.yaml
130
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
|
||||
|
|
|
|||
|
|
@ -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)];
|
||||
|
|
|
|||
Loading…
Reference in New Issue