mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-19 16:16:45 +00:00
feat(mcp): add native installs and resource discovery
This commit is contained in:
@@ -0,0 +1,33 @@
|
||||
class DbxMcp < Formula
|
||||
desc "Native MCP server for DBX database connections, schema, and safe queries"
|
||||
homepage "https://github.com/t8y2/dbx"
|
||||
version "__VERSION__"
|
||||
license "Apache-2.0"
|
||||
|
||||
on_macos do
|
||||
if Hardware::CPU.intel?
|
||||
url "https://github.com/t8y2/dbx/releases/download/packages-v#{version}/dbx-mcp-darwin-x64.tar.gz"
|
||||
sha256 "__SHA_DARWIN_X64__"
|
||||
else
|
||||
url "https://github.com/t8y2/dbx/releases/download/packages-v#{version}/dbx-mcp-darwin-arm64.tar.gz"
|
||||
sha256 "__SHA_DARWIN_ARM64__"
|
||||
end
|
||||
end
|
||||
on_linux do
|
||||
if Hardware::CPU.intel?
|
||||
url "https://github.com/t8y2/dbx/releases/download/packages-v#{version}/dbx-mcp-linux-x64-gnu.tar.gz"
|
||||
sha256 "__SHA_LINUX_X64__"
|
||||
else
|
||||
url "https://github.com/t8y2/dbx/releases/download/packages-v#{version}/dbx-mcp-linux-arm64-gnu.tar.gz"
|
||||
sha256 "__SHA_LINUX_ARM64__"
|
||||
end
|
||||
end
|
||||
|
||||
def install
|
||||
bin.install "dbx-mcp"
|
||||
end
|
||||
|
||||
test do
|
||||
assert_match version.to_s, shell_output("#{bin}/dbx-mcp --version")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,114 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$source = Get-Content -LiteralPath (Join-Path $PSScriptRoot '../../docs/public/install-mcp.ps1') -Raw
|
||||
. ([scriptblock]::Create(($source -replace '(?m)^Install-DbxMcp\s*$', '')))
|
||||
$originalHome = $HOME
|
||||
$testRoot = Join-Path ([IO.Path]::GetTempPath()) ('dbx-mcp-ps-test-' + [Guid]::NewGuid().ToString('N'))
|
||||
[IO.Directory]::CreateDirectory($testRoot) | Out-Null
|
||||
$realTar = (Get-Command tar).Source
|
||||
$script:count = 0
|
||||
|
||||
function Assert-True($Condition, [string]$Message) { if (!$Condition) { throw $Message } }
|
||||
function Get-McpPlatform { return $script:platform }
|
||||
function Set-InstallPath([string]$Directory) { $script:configuredPath = $Directory }
|
||||
function tar.exe { & $realTar @args }
|
||||
function Get-Download([string]$Url, [string]$Destination) {
|
||||
$script:requests += $Url
|
||||
if ($script:mode -eq 'offline') { return $false }
|
||||
if ($script:mode -in 'mirror', 'github' -and $Url.Contains('registry.npmjs.org')) { return $false }
|
||||
if ($script:mode -eq 'github' -and $Url.Contains('registry.npmmirror.com')) { return $false }
|
||||
$file = switch -Regex ($Url) {
|
||||
'/latest$' { 'latest.json'; break }
|
||||
'/matching-refs/' { 'refs.json'; break }
|
||||
'/0\.4\.96$' { 'metadata.json'; break }
|
||||
'\.tgz$' { 'npm.tgz'; break }
|
||||
'/SHA256SUMS$' { 'SHA256SUMS'; break }
|
||||
'\.zip$' { 'github.zip'; break }
|
||||
default { throw "Unexpected URL: $Url" }
|
||||
}
|
||||
Copy-Item -LiteralPath (Join-Path $script:fixture $file) -Destination $Destination -Force
|
||||
return $true
|
||||
}
|
||||
function New-Fixture([string]$Platform, [string]$Mode) {
|
||||
$script:platform = $Platform
|
||||
$script:mode = $Mode
|
||||
$script:requests = @()
|
||||
$script:fixture = Join-Path $testRoot ([Guid]::NewGuid().ToString('N'))
|
||||
$homePath = Join-Path $script:fixture "DBX User's home"
|
||||
[IO.Directory]::CreateDirectory($homePath) | Out-Null
|
||||
Set-Variable HOME -Scope Global -Force -Value $homePath
|
||||
$packageBin = Join-Path $script:fixture 'source/package/bin'
|
||||
[IO.Directory]::CreateDirectory($packageBin) | Out-Null
|
||||
[IO.File]::WriteAllText((Join-Path $packageBin 'dbx-mcp.exe'), 'fixture binary')
|
||||
& $realTar -czf (Join-Path $script:fixture 'npm.tgz') -C (Join-Path $script:fixture 'source') 'package/bin/dbx-mcp.exe'
|
||||
if ($LASTEXITCODE) { throw 'Fixture tar failed' }
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
[IO.Compression.ZipFile]::CreateFromDirectory($packageBin, (Join-Path $script:fixture 'github.zip'))
|
||||
$stream = [IO.File]::OpenRead((Join-Path $script:fixture 'npm.tgz'))
|
||||
$hasher = [Security.Cryptography.SHA512]::Create()
|
||||
try { $integrity = 'sha512-' + [Convert]::ToBase64String($hasher.ComputeHash($stream)) }
|
||||
finally { $stream.Dispose(); $hasher.Dispose() }
|
||||
@{ version = '0.4.96' } | ConvertTo-Json | Set-Content (Join-Path $script:fixture 'latest.json')
|
||||
@{ version = '0.4.96'; dist = @{ integrity = $integrity } } | ConvertTo-Json | Set-Content (Join-Path $script:fixture 'metadata.json')
|
||||
@(@{ ref = 'refs/tags/packages-v0.4.9' }, @{ ref = 'refs/tags/packages-v0.4.96' }, @{ ref = 'refs/tags/packages-v9.0.0-beta' }) | ConvertTo-Json | Set-Content (Join-Path $script:fixture 'refs.json')
|
||||
$hash = (Get-FileHash (Join-Path $script:fixture 'github.zip') -Algorithm SHA256).Hash
|
||||
"$hash dbx-mcp-$Platform.zip" | Set-Content (Join-Path $script:fixture 'SHA256SUMS')
|
||||
$script:binary = Join-Path $HOME '.dbx/bin/dbx-mcp.exe'
|
||||
$script:marker = Join-Path $HOME '.dbx/bin/.dbx-mcp-version'
|
||||
}
|
||||
function Expect-Failure([string]$Pattern) {
|
||||
try { Install-DbxMcp | Out-Null } catch {
|
||||
Assert-True ($_.Exception.Message -match $Pattern) "Unexpected error: $_"
|
||||
return
|
||||
}
|
||||
throw 'Expected installation to fail'
|
||||
}
|
||||
|
||||
try {
|
||||
foreach ($platformName in @('win32-arm64', 'win32-x64')) {
|
||||
foreach ($sourceMode in @('npm', 'mirror', 'github')) {
|
||||
New-Fixture $platformName $sourceMode
|
||||
$output = Install-DbxMcp | Out-String
|
||||
Assert-True ((Get-Content -LiteralPath $binary -Raw) -eq 'fixture binary') 'First install binary differs'
|
||||
Assert-True ((Get-Content -LiteralPath $marker -Raw).Trim() -eq '0.4.96') 'Marker differs'
|
||||
Assert-True ($output.Contains((ConvertTo-Json -InputObject $binary -Compress))) 'Config is not the escaped absolute path'
|
||||
Assert-True ($configuredPath -eq (Split-Path $binary)) 'PATH target differs'
|
||||
Assert-True (!(Get-ChildItem -LiteralPath (Split-Path $binary) -Directory -Force)) 'Temporary install directory leaked'
|
||||
$rerun = Install-DbxMcp | Out-String
|
||||
Assert-True ($rerun.Contains('dbx-mcp 0.4.96 already up to date')) 'Rerun not idempotent'
|
||||
'0.4.95' | Set-Content -LiteralPath $marker
|
||||
$updated = Install-DbxMcp | Out-String
|
||||
Assert-True ($updated.Contains('Installed dbx-mcp 0.4.96')) 'Marker-based upgrade failed'
|
||||
Remove-Item -LiteralPath $binary
|
||||
$repaired = Install-DbxMcp | Out-String
|
||||
Assert-True ($repaired.Contains('Installed dbx-mcp 0.4.96')) 'Orphan marker prevented installation'
|
||||
$script:count++
|
||||
Write-Output "PASS $platformName $sourceMode install/rerun/update/configs"
|
||||
}
|
||||
}
|
||||
foreach ($sourceMode in @('npm', 'github', 'offline')) {
|
||||
New-Fixture 'win32-x64' 'npm'
|
||||
Install-DbxMcp | Out-Null
|
||||
'0.4.95' | Set-Content -LiteralPath $marker
|
||||
$script:mode = $sourceMode
|
||||
if ($sourceMode -eq 'npm') { 'corrupted' | Set-Content (Join-Path $fixture 'npm.tgz') }
|
||||
if ($sourceMode -eq 'github') { 'corrupted' | Set-Content (Join-Path $fixture 'github.zip') }
|
||||
Expect-Failure $(if ($sourceMode -eq 'offline') { 'Unable to resolve' } else { 'verification failed' })
|
||||
Assert-True ((Get-Content -LiteralPath $binary -Raw) -eq 'fixture binary') 'Failure replaced binary'
|
||||
Assert-True ((Get-Content -LiteralPath $marker -Raw).Trim() -eq '0.4.95') 'Failure replaced marker'
|
||||
$script:count++
|
||||
Write-Output "PASS $sourceMode failure preserves installation"
|
||||
}
|
||||
New-Fixture 'win32-x64' 'npm'
|
||||
Install-DbxMcp | Out-Null
|
||||
'0.4.97' | Set-Content -LiteralPath $marker
|
||||
$script:mode = 'mirror'
|
||||
$newer = Install-DbxMcp | Out-String
|
||||
Assert-True ($newer.Contains('keeping current installation')) 'Older mirror downgraded installation'
|
||||
Assert-True ((Get-Content -LiteralPath $marker -Raw).Trim() -eq '0.4.97') 'Downgrade changed marker'
|
||||
$script:count++
|
||||
Write-Output 'PASS older mirror cannot downgrade installation'
|
||||
Write-Output "PowerShell installer scenarios passed: $script:count"
|
||||
} finally {
|
||||
Set-Variable HOME -Scope Global -Force -Value $originalHome
|
||||
Remove-Item -LiteralPath $testRoot -Recurse -Force
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join, resolve } from "node:path";
|
||||
import test from "node:test";
|
||||
|
||||
const installer = resolve("docs/public/install-mcp.sh");
|
||||
const platforms = ["darwin-arm64", "darwin-x64", "linux-arm64-gnu", "linux-x64-gnu"];
|
||||
|
||||
function fixture(context, platform = "darwin-arm64") {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), "dbx-mcp-test-")));
|
||||
context.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const home = join(root, "DBX User's 中文");
|
||||
const mocks = join(root, "mocks");
|
||||
const source = join(root, "source");
|
||||
mkdirSync(home);
|
||||
mkdirSync(mocks);
|
||||
mkdirSync(join(source, "package/bin"), { recursive: true });
|
||||
const binary = join(source, "package/bin/dbx-mcp");
|
||||
writeFileSync(binary, "#!/bin/sh\nprintf 'dbx-mcp 0.4.96\\n'\n", { mode: 0o755 });
|
||||
copyFileSync(binary, join(source, "dbx-mcp"));
|
||||
for (const [archive, member] of [["npm.tgz", "package/bin/dbx-mcp"], ["github.tar.gz", "dbx-mcp"]]) {
|
||||
const result = spawnSync("tar", ["-czf", join(root, archive), "-C", source, member]);
|
||||
assert.equal(result.status, 0, result.stderr.toString());
|
||||
}
|
||||
const integrity = "sha512-" + createHash("sha512").update(readFileSync(join(root, "npm.tgz"))).digest("base64");
|
||||
const checksum = createHash("sha256").update(readFileSync(join(root, "github.tar.gz"))).digest("hex");
|
||||
writeFileSync(join(root, "latest.json"), JSON.stringify({ description: 'ignore "version":"99.0.0"', version: "0.4.96" }));
|
||||
writeFileSync(join(root, "metadata.json"), JSON.stringify({ name: "test", version: "0.4.96", dist: { integrity } }));
|
||||
writeFileSync(join(root, "refs.json"), JSON.stringify([
|
||||
{ ref: "refs/tags/packages-v0.4.9", object: {} },
|
||||
{ ref: "refs/tags/packages-v0.4.96", object: {} },
|
||||
{ ref: "refs/tags/packages-v1.0.0-beta.1", object: {} },
|
||||
{ ref: "refs/tags/packages-v0.4.10", object: {} },
|
||||
]));
|
||||
writeFileSync(join(root, "SHA256SUMS"), `${checksum} dbx-mcp-${platform}.tar.gz\n`);
|
||||
const routes = {};
|
||||
for (const registry of ["https://registry.npmjs.org", "https://registry.npmmirror.com"]) {
|
||||
routes[`${registry}/@dbx-app/mcp-server/latest`] = "latest.json";
|
||||
routes[`${registry}/@dbx-app/mcp-${platform}/0.4.96`] = "metadata.json";
|
||||
routes[`${registry}/@dbx-app/mcp-${platform}/-/mcp-${platform}-0.4.96.tgz`] = "npm.tgz";
|
||||
}
|
||||
routes["https://api.github.com/repos/t8y2/dbx/git/matching-refs/tags/packages-v"] = "refs.json";
|
||||
routes["https://github.com/t8y2/dbx/releases/download/packages-v0.4.96/SHA256SUMS"] = "SHA256SUMS";
|
||||
routes[`https://github.com/t8y2/dbx/releases/download/packages-v0.4.96/dbx-mcp-${platform}.tar.gz`] = "github.tar.gz";
|
||||
writeFileSync(join(root, "routes.json"), JSON.stringify(routes));
|
||||
writeFileSync(join(mocks, "curl"), `#!${process.execPath}
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const args = process.argv.slice(2);
|
||||
const url = args.find(arg => arg.startsWith('https://'));
|
||||
const root = process.env.FIXTURE;
|
||||
fs.appendFileSync(path.join(root, 'requests.log'), url + '\\n');
|
||||
if (process.env.FAIL_URL && new RegExp(process.env.FAIL_URL).test(url)) process.exit(22);
|
||||
const routes = JSON.parse(fs.readFileSync(path.join(root, 'routes.json')));
|
||||
if (!routes[url]) process.exit(22);
|
||||
fs.copyFileSync(path.join(root, routes[url]), args[args.indexOf('-o') + 1]);
|
||||
`, { mode: 0o755 });
|
||||
writeFileSync(join(mocks, "uname"), `#!/bin/sh\ncase "$1" in -s) echo '${platform.startsWith("darwin") ? "Darwin" : "Linux"}';; -m) echo '${platform.includes("arm64") ? "aarch64" : "x86_64"}';; esac\n`, { mode: 0o755 });
|
||||
const installedBinary = join(home, ".dbx/bin/dbx-mcp");
|
||||
const marker = join(home, ".dbx/bin/.dbx-mcp-version");
|
||||
const run = (extra = {}) => spawnSync("sh", [installer], {
|
||||
env: { ...process.env, HOME: home, SHELL: "/bin/zsh", ZDOTDIR: home, PATH: `${mocks}:${process.env.PATH}`, FIXTURE: root, ...extra },
|
||||
encoding: "utf8", timeout: 30_000,
|
||||
});
|
||||
return { root, home, mocks, binary: installedBinary, marker, run };
|
||||
}
|
||||
|
||||
for (const platform of platforms) {
|
||||
test(`native install and absolute configs: ${platform}`, (context) => {
|
||||
const setup = fixture(context, platform);
|
||||
const result = setup.run();
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.equal(spawnSync(setup.binary, ["--version"], { encoding: "utf8" }).stdout.trim(), "dbx-mcp 0.4.96");
|
||||
assert.equal(readFileSync(setup.marker, "utf8"), "0.4.96\n");
|
||||
const json = JSON.parse(result.stdout.split("\n").find((line) => line.startsWith('{"mcpServers"')));
|
||||
assert.equal(json.mcpServers.dbx.command, setup.binary);
|
||||
const tomlCommand = result.stdout.split("\n").find((line) => line.startsWith("command = ")).slice(10);
|
||||
assert.equal(JSON.parse(tomlCommand), setup.binary);
|
||||
assert.deepEqual(readdirSync(join(setup.home, ".dbx/bin")).sort(), [".dbx-mcp-version", "dbx-mcp"]);
|
||||
});
|
||||
}
|
||||
|
||||
test("rerun is idempotent, binary version precedes marker, legacy marker enables update", (context) => {
|
||||
const setup = fixture(context);
|
||||
assert.equal(setup.run().status, 0);
|
||||
writeFileSync(setup.marker, "0.4.95\n");
|
||||
const unchanged = setup.run();
|
||||
assert.equal(unchanged.status, 0, unchanged.stderr);
|
||||
assert.match(unchanged.stdout, /dbx-mcp 0.4.96 already up to date/);
|
||||
assert.equal(readFileSync(setup.marker, "utf8"), "0.4.95\n");
|
||||
writeFileSync(setup.binary, "#!/bin/sh\nprintf 'dbx-mcp 0.4.96\\n'\nexit 1\n");
|
||||
const updated = setup.run();
|
||||
assert.equal(updated.status, 0, updated.stderr);
|
||||
assert.match(updated.stdout, /Installed dbx-mcp 0.4.96/);
|
||||
assert.equal(readFileSync(setup.marker, "utf8"), "0.4.96\n");
|
||||
writeFileSync(setup.binary, "#!/bin/sh\nexit 1\n");
|
||||
assert.match(setup.run().stdout, /already up to date/);
|
||||
assert.equal(readFileSync(join(setup.home, ".zshrc"), "utf8").match(/# added by dbx installer/g).length, 1);
|
||||
});
|
||||
|
||||
for (const [name, failUrl, expected] of [
|
||||
["npmmirror", "registry\\.npmjs\\.org", "registry.npmmirror.com"],
|
||||
["GitHub", "registry\\.", "github.com/t8y2/dbx/releases/download/packages-v0.4.96"],
|
||||
]) {
|
||||
test(`falls back to ${name} for metadata and archive`, (context) => {
|
||||
const setup = fixture(context);
|
||||
const result = setup.run({ FAIL_URL: failUrl });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(readFileSync(join(setup.root, "requests.log"), "utf8").includes(expected));
|
||||
});
|
||||
}
|
||||
|
||||
test("download fallback retains the version selected by npmjs", (context) => {
|
||||
const setup = fixture(context);
|
||||
const result = setup.run({ FAIL_URL: "registry\\.npmjs\\.org/.*mcp-darwin" });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(readFileSync(join(setup.root, "requests.log"), "utf8"), /npmmirror.com\/.*0.4.96.tgz/);
|
||||
});
|
||||
|
||||
test("an older mirror version cannot downgrade an existing native installation", (context) => {
|
||||
const setup = fixture(context);
|
||||
assert.equal(setup.run().status, 0);
|
||||
const newer = "#!/bin/sh\nprintf 'dbx-mcp 0.4.97\\n'\n";
|
||||
writeFileSync(setup.binary, newer);
|
||||
writeFileSync(setup.marker, "0.4.97\n");
|
||||
const result = setup.run({ FAIL_URL: "registry\\.npmjs\\.org" });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.match(result.stdout, /keeping current installation/);
|
||||
assert.equal(readFileSync(setup.binary, "utf8"), newer);
|
||||
assert.equal(readFileSync(setup.marker, "utf8"), "0.4.97\n");
|
||||
});
|
||||
|
||||
test("offline exits with one concise error and preserves the existing installation", (context) => {
|
||||
const setup = fixture(context);
|
||||
assert.equal(setup.run().status, 0);
|
||||
const original = readFileSync(setup.binary);
|
||||
const result = setup.run({ FAIL_URL: ".*" });
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.equal(result.stdout, "");
|
||||
assert.equal(result.stderr.trim().split("\n").length, 1);
|
||||
assert.match(result.stderr, /existing installation unchanged/);
|
||||
assert.deepEqual(readFileSync(setup.binary), original);
|
||||
});
|
||||
|
||||
for (const source of ["npm", "github"]) {
|
||||
test(`rejects ${source} checksum mismatch without replacing anything`, (context) => {
|
||||
const setup = fixture(context);
|
||||
mkdirSync(join(setup.home, ".dbx/bin"), { recursive: true });
|
||||
writeFileSync(setup.binary, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
writeFileSync(setup.marker, "0.4.95\n");
|
||||
const original = readFileSync(setup.binary);
|
||||
writeFileSync(join(setup.root, source === "npm" ? "npm.tgz" : "github.tar.gz"), "corrupted");
|
||||
const result = setup.run(source === "github" ? { FAIL_URL: "registry\\." } : {});
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /verification failed/);
|
||||
assert.deepEqual(readFileSync(setup.binary), original);
|
||||
assert.equal(readFileSync(setup.marker, "utf8"), "0.4.95\n");
|
||||
assert.ok(!existsSync(join(setup.home, ".zshrc")));
|
||||
});
|
||||
}
|
||||
|
||||
test("stale marker without binary reinstalls, and bash users get migration guidance", (context) => {
|
||||
const setup = fixture(context);
|
||||
mkdirSync(join(setup.home, ".dbx/bin"), { recursive: true });
|
||||
writeFileSync(setup.marker, "0.4.96\n");
|
||||
writeFileSync(join(setup.mocks, "dbx-mcp-server"), "#!/bin/sh\nexit 0\n", { mode: 0o755 });
|
||||
const result = setup.run({ SHELL: "/bin/bash" });
|
||||
assert.equal(result.status, 0, result.stderr);
|
||||
assert.ok(existsSync(setup.binary));
|
||||
assert.ok(existsSync(join(setup.home, ".bash_profile")));
|
||||
assert.match(result.stdout, /npm rm -g @dbx-app\/mcp-server/);
|
||||
});
|
||||
|
||||
test("formula generator validates all hashes and both publishing workflows invoke it", (context) => {
|
||||
const setup = fixture(context);
|
||||
const sums = join(setup.root, "formula-sums");
|
||||
const output = join(setup.root, "dbx-mcp.rb");
|
||||
writeFileSync(sums, platforms.map((platform) => `${"a".repeat(64)} dbx-mcp-${platform}.tar.gz`).join("\n"));
|
||||
const generate = () => spawnSync(process.execPath, [".github/scripts/render-mcp-formula.mjs", "0.4.96", sums, output], { encoding: "utf8" });
|
||||
assert.equal(generate().status, 0);
|
||||
const formula = readFileSync(output, "utf8");
|
||||
assert.match(formula, /bin.install "dbx-mcp"/);
|
||||
assert.doesNotMatch(formula, /__[A-Z_]+__/);
|
||||
assert.equal((formula.match(/sha256 /g) ?? []).length, 4);
|
||||
writeFileSync(sums, "invalid");
|
||||
assert.notEqual(generate().status, 0);
|
||||
assert.equal(readFileSync(output, "utf8"), formula);
|
||||
for (const workflow of ["mcp-release", "publish-packages"]) {
|
||||
const text = readFileSync(`.github/workflows/${workflow}.yml`, "utf8");
|
||||
const checkoutIndex = text.indexOf("actions/checkout@");
|
||||
const renderIndex = text.indexOf("render-mcp-formula.mjs");
|
||||
assert.notEqual(checkoutIndex, -1);
|
||||
assert.notEqual(renderIndex, -1);
|
||||
assert.ok(checkoutIndex < renderIndex);
|
||||
assert.match(text, /render-mcp-formula.mjs/);
|
||||
assert.match(text, /git add .*Formula\/dbx-mcp.rb/);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const [version, sumsPath, outputPath] = process.argv.slice(2);
|
||||
if (!/^\d+\.\d+\.\d+$/.test(version ?? "") || !sumsPath || !outputPath) {
|
||||
throw new Error("Usage: render-mcp-formula.mjs <version> <SHA256SUMS> <output.rb>");
|
||||
}
|
||||
const sums = readFileSync(sumsPath, "utf8").trim().split(/\r?\n/);
|
||||
let formula = readFileSync(new URL("./dbx-mcp.rb.template", import.meta.url), "utf8").replaceAll("__VERSION__", version);
|
||||
for (const [platform, placeholder] of [
|
||||
["darwin-arm64", "__SHA_DARWIN_ARM64__"],
|
||||
["darwin-x64", "__SHA_DARWIN_X64__"],
|
||||
["linux-arm64-gnu", "__SHA_LINUX_ARM64__"],
|
||||
["linux-x64-gnu", "__SHA_LINUX_X64__"],
|
||||
]) {
|
||||
const matches = sums.map((line) => line.trim().split(/\s+/)).filter(([, name]) => name === `dbx-mcp-${platform}.tar.gz`);
|
||||
if (matches.length !== 1 || !/^[a-fA-F0-9]{64}$/.test(matches[0][0])) {
|
||||
throw new Error(`Missing, duplicate, or invalid SHA256 for ${platform}`);
|
||||
}
|
||||
formula = formula.replaceAll(placeholder, matches[0][0].toLowerCase());
|
||||
}
|
||||
writeFileSync(outputPath, formula);
|
||||
@@ -160,6 +160,10 @@ jobs:
|
||||
- name: GitHub script tests
|
||||
run: node --test .github/scripts/*.test.mjs
|
||||
|
||||
- name: PowerShell MCP installer tests
|
||||
shell: pwsh
|
||||
run: ./.github/scripts/install-mcp-powershell.test.ps1
|
||||
|
||||
packages:
|
||||
needs: changes
|
||||
if: needs.changes.outputs.packages == 'true'
|
||||
@@ -1052,6 +1056,9 @@ jobs:
|
||||
- 'vendor/wry/**'
|
||||
github_scripts:
|
||||
- '.github/scripts/**'
|
||||
- 'docs/public/install-mcp*'
|
||||
- '.github/workflows/mcp-release.yml'
|
||||
- '.github/workflows/publish-packages.yml'
|
||||
- '.github/workflows/plugin-release-reusable.yml'
|
||||
- '.github/workflows/ci.yml'
|
||||
- 'apps/desktop/src/types/database.ts'
|
||||
|
||||
@@ -931,8 +931,19 @@ jobs:
|
||||
publish-homebrew-formula:
|
||||
name: Publish Homebrew formula
|
||||
runs-on: ubuntu-latest
|
||||
needs: [prepare, publish-cli-github-release]
|
||||
needs: [prepare, publish-cli-github-release, publish-mcp-github-release]
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
- name: Download MCP release checksums
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
TAG: ${{ needs.prepare.outputs.tag }}
|
||||
run: |
|
||||
mkdir -p release-assets
|
||||
gh release download "${TAG}" --repo "${GITHUB_REPOSITORY}" \
|
||||
--pattern SHA256SUMS --dir release-assets --clobber
|
||||
|
||||
- name: Download native CLI archives and compute SHA256
|
||||
id: cli-hash
|
||||
env:
|
||||
@@ -1045,14 +1056,17 @@ jobs:
|
||||
sed -i "s/__SHA_LINUX_ARM64__/${SHA_LINUX_ARM64}/g" Formula/dbx-cli.rb
|
||||
sed -i "s/__SHA_LINUX_X64__/${SHA_LINUX_X64}/g" Formula/dbx-cli.rb
|
||||
|
||||
node "$GITHUB_WORKSPACE/.github/scripts/render-mcp-formula.mjs" \
|
||||
"${CLI_VERSION}" "$GITHUB_WORKSPACE/release-assets/SHA256SUMS" Formula/dbx-mcp.rb
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add Formula/dbx-cli.rb
|
||||
git add Formula/dbx-cli.rb Formula/dbx-mcp.rb
|
||||
|
||||
if git diff --cached --quiet; then
|
||||
echo "CLI Homebrew formula is already up to date."
|
||||
else
|
||||
git commit -m "dbx-cli ${CLI_VERSION}"
|
||||
git commit -m "dbx-cli and dbx-mcp ${CLI_VERSION}"
|
||||
git push
|
||||
echo "::notice::CLI Homebrew formula updated to ${CLI_VERSION}"
|
||||
fi
|
||||
|
||||
@@ -25,6 +25,9 @@ jobs:
|
||||
name: Publish to Homebrew & Scoop
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Extract version from tag
|
||||
id: version
|
||||
run: |
|
||||
@@ -91,11 +94,17 @@ jobs:
|
||||
- name: Publish Homebrew Cask
|
||||
env:
|
||||
TAP_GITHUB_TOKEN: ${{ secrets.TAP_GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
SHA_ARM64="${{ steps.hashes.outputs.sha_dmg_arm64 }}"
|
||||
SHA_X64="${{ steps.hashes.outputs.sha_dmg_x64 }}"
|
||||
|
||||
MCP_VERSION="$(curl -fsSL https://registry.npmjs.org/@dbx-app/mcp-server/latest | jq -er '.version')"
|
||||
[[ "$MCP_VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || exit 1
|
||||
gh release download "packages-v${MCP_VERSION}" --repo "${GITHUB_REPOSITORY}" \
|
||||
--pattern SHA256SUMS --dir artifacts --clobber
|
||||
|
||||
git clone --depth 1 \
|
||||
"https://x-access-token:${TAP_GITHUB_TOKEN}@github.com/${REPO_OWNER}/homebrew-tap.git" \
|
||||
homebrew-tap
|
||||
@@ -137,9 +146,13 @@ jobs:
|
||||
sed -i "s/__SHA_ARM64__/${SHA_ARM64}/g" Casks/dbx.rb
|
||||
sed -i "s/__SHA_X64__/${SHA_X64}/g" Casks/dbx.rb
|
||||
|
||||
mkdir -p Formula
|
||||
node "$GITHUB_WORKSPACE/.github/scripts/render-mcp-formula.mjs" \
|
||||
"${MCP_VERSION}" "$GITHUB_WORKSPACE/artifacts/SHA256SUMS" Formula/dbx-mcp.rb
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add Casks/dbx.rb
|
||||
git add Casks/dbx.rb Formula/dbx-mcp.rb
|
||||
|
||||
if git diff --cached --quiet; then
|
||||
echo "Homebrew Cask is already up to date."
|
||||
|
||||
@@ -132,7 +132,9 @@ import {
|
||||
aiTestConnection,
|
||||
checkMcpServerStatus,
|
||||
installMcpServer,
|
||||
installNativeMcpServer,
|
||||
uninstallMcpServer,
|
||||
uninstallNpmMcpServer,
|
||||
loadMcpHttpServerSettings,
|
||||
saveMcpHttpServerSettings,
|
||||
mcpHttpServerStatus,
|
||||
@@ -206,6 +208,7 @@ import {
|
||||
buildMcpWorkBuddyConfig,
|
||||
mcpWebBackendUrl,
|
||||
type McpLaunchConfig,
|
||||
preferMcpNativeLaunch,
|
||||
} from "@/lib/mcp/mcpConfigTemplates";
|
||||
import { beginMcpStatusRequest, mcpUpdateAvailability } from "@/lib/mcp/mcpUpdateStatus";
|
||||
import { notifyComponentUpdatesChanged } from "@/lib/updates/componentUpdateEvents";
|
||||
@@ -3864,6 +3867,9 @@ const mcpLaunchConfig = computed<McpLaunchConfig | undefined>(() => {
|
||||
};
|
||||
}
|
||||
const env = mcpStatus.value?.data_dir ? { DBX_DATA_DIR: mcpStatus.value.data_dir } : undefined;
|
||||
if (mcpStatus.value?.native_bin_path) {
|
||||
return preferMcpNativeLaunch({ command: "dbx-mcp-server", env }, mcpStatus.value.native_bin_path);
|
||||
}
|
||||
if (mcpStatus.value?.node_path && mcpStatus.value.script_path) {
|
||||
return {
|
||||
command: mcpStatus.value.node_path,
|
||||
@@ -3879,17 +3885,9 @@ const mcpLaunchConfig = computed<McpLaunchConfig | undefined>(() => {
|
||||
|
||||
const mcpJsonRecommendedConfig = computed(() => buildMcpJsonConfig(mcpLaunchConfig.value));
|
||||
|
||||
const mcpTraeRecommendedConfig = computed(() => {
|
||||
// TRAE currently splits Windows executable paths containing spaces, so bypass Node and launch the native MCP binary directly.
|
||||
const nativeBinPath = !isWeb && isWindows() ? mcpStatus.value?.native_bin_path : undefined;
|
||||
return buildMcpTraeConfig(mcpLaunchConfig.value, nativeBinPath ?? undefined);
|
||||
});
|
||||
const mcpTraeRecommendedConfig = computed(() => buildMcpTraeConfig(mcpLaunchConfig.value));
|
||||
|
||||
const mcpQoderRecommendedConfig = computed(() => {
|
||||
// Qoder follows TRAE's mcpServers JSON format and has the same Windows path parsing limitation.
|
||||
const nativeBinPath = !isWeb && isWindows() ? mcpStatus.value?.native_bin_path : undefined;
|
||||
return buildMcpQoderConfig(mcpLaunchConfig.value, nativeBinPath ?? undefined);
|
||||
});
|
||||
const mcpQoderRecommendedConfig = computed(() => buildMcpQoderConfig(mcpLaunchConfig.value));
|
||||
|
||||
const mcpVsCodeRecommendedConfig = computed(() => buildMcpVsCodeConfig(mcpLaunchConfig.value));
|
||||
|
||||
@@ -3918,11 +3916,19 @@ const mcpStatusLabel = computed(() => {
|
||||
});
|
||||
|
||||
const mcpCommand = computed(() => {
|
||||
if (!mcpStatus.value) return "npm install -g @dbx-app/mcp-server@latest";
|
||||
if (!mcpStatus.value) return isWindows() ? 'powershell -NoProfile -Command "irm https://dbxio.com/install-mcp.ps1 | iex"' : "curl -fsSL https://dbxio.com/install-mcp | sh";
|
||||
return mcpStatus.value.installed ? mcpStatus.value.update_command : mcpStatus.value.install_command;
|
||||
});
|
||||
|
||||
const mcpUninstallCommand = computed(() => mcpStatus.value?.uninstall_command || "npm uninstall -g @dbx-app/mcp-server");
|
||||
const mcpNativeMigrationAvailable = computed(() => mcpStatus.value?.installation_source === "npm");
|
||||
const mcpInstallDisabled = computed(() => mcpInstalling.value || mcpUninstalling.value || Boolean(mcpStatus.value?.installed && !mcpStatus.value.update_available && !mcpNativeMigrationAvailable.value));
|
||||
const mcpInstallButtonLabel = computed(() => {
|
||||
if (mcpInstalling.value) return t("settings.mcpInstalling");
|
||||
if (mcpNativeMigrationAvailable.value) return t("settings.mcpSwitchToNativeButton");
|
||||
if (!mcpStatus.value?.installed) return t("settings.mcpInstallButton");
|
||||
return mcpStatus.value.update_available ? t("settings.mcpUpdateButton") : t("settings.mcpUpToDate");
|
||||
});
|
||||
|
||||
async function refreshMcpStatus() {
|
||||
if (mcpStatusLoading.value) return;
|
||||
@@ -3963,7 +3969,7 @@ async function installMcp() {
|
||||
mcpInstallMessage.value = "";
|
||||
mcpInstallError.value = false;
|
||||
try {
|
||||
const result = await installMcpServer();
|
||||
const result = mcpNativeMigrationAvailable.value || !mcpStatus.value?.installed ? await installNativeMcpServer() : await installMcpServer();
|
||||
mcpInstallMessage.value = result;
|
||||
mcpInstallError.value = false;
|
||||
// 安装成功后刷新状态
|
||||
@@ -4004,6 +4010,28 @@ async function uninstallMcp() {
|
||||
}
|
||||
}
|
||||
|
||||
async function uninstallNpmMcpFallback() {
|
||||
if (mcpInstalling.value || mcpUninstalling.value || !mcpStatus.value?.npm_installed) return;
|
||||
if (!window.confirm(t("settings.mcpRemoveNpmFallbackConfirm"))) return;
|
||||
mcpUninstalling.value = true;
|
||||
mcpInstallMessage.value = "";
|
||||
mcpInstallError.value = false;
|
||||
try {
|
||||
mcpInstallMessage.value = await uninstallNpmMcpServer();
|
||||
await refreshMcpStatus();
|
||||
notifyComponentUpdatesChanged();
|
||||
} catch (e: any) {
|
||||
mcpInstallMessage.value = t("settings.mcpRemoveNpmFallbackFailed", { error: e?.message || String(e) });
|
||||
mcpInstallError.value = true;
|
||||
} finally {
|
||||
mcpUninstalling.value = false;
|
||||
window.setTimeout(() => {
|
||||
mcpInstallMessage.value = "";
|
||||
mcpInstallError.value = false;
|
||||
}, 3000);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- WebDAV Sync ----------
|
||||
const webdavEndpoint = ref(localStorage.getItem("dbx-webdav-endpoint") || "");
|
||||
const webdavUsername = ref(localStorage.getItem("dbx-webdav-username") || "");
|
||||
@@ -9923,7 +9951,10 @@ LIMIT 100;</pre
|
||||
</div>
|
||||
|
||||
<div v-if="!isWeb" class="space-y-2">
|
||||
<Label>{{ mcpStatus?.installed ? t("settings.mcpUpdateCommand") : t("settings.mcpInstallCommand") }}</Label>
|
||||
<Label>{{ mcpStatus?.installed && !mcpNativeMigrationAvailable ? t("settings.mcpUpdateCommand") : t("settings.mcpInstallCommand") }}</Label>
|
||||
<p v-if="mcpNativeMigrationAvailable" class="rounded-md border border-blue-500/30 bg-blue-500/5 px-3 py-2 text-xs text-blue-700 dark:text-blue-300">
|
||||
{{ t("settings.mcpNativeMigrationHint") }}
|
||||
</p>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<div class="min-w-0 flex-1 overflow-x-auto rounded-md border bg-background px-3 py-2 font-mono text-xs whitespace-nowrap">
|
||||
{{ mcpCommand }}
|
||||
@@ -9932,10 +9963,10 @@ LIMIT 100;</pre
|
||||
<CheckCircle2 v-if="mcpCopied === 'install'" class="h-4 w-4 text-green-500" />
|
||||
<Copy v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button type="button" variant="default" :disabled="mcpInstalling || mcpUninstalling || !mcpStatus?.npm_available || (mcpStatus?.installed && !mcpStatus?.update_available)" @click="installMcp">
|
||||
<Button type="button" variant="default" :disabled="mcpInstallDisabled" @click="installMcp">
|
||||
<Loader2 v-if="mcpInstalling" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<CheckCircle2 v-if="!mcpInstalling && mcpStatus?.installed && !mcpStatus?.update_available" class="mr-2 h-4 w-4" />
|
||||
{{ mcpInstalling ? t("settings.mcpInstalling") : !mcpStatus?.installed ? t("settings.mcpInstallButton") : mcpStatus?.update_available ? t("settings.mcpUpdateButton") : t("settings.mcpUpToDate") }}
|
||||
<CheckCircle2 v-if="!mcpInstalling && mcpStatus?.installed && !mcpStatus?.update_available && !mcpNativeMigrationAvailable" class="mr-2 h-4 w-4" />
|
||||
{{ mcpInstallButtonLabel }}
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
@@ -9959,13 +9990,26 @@ LIMIT 100;</pre
|
||||
<CheckCircle2 v-if="mcpCopied === 'uninstall'" class="h-4 w-4 text-green-500" />
|
||||
<Copy v-else class="h-4 w-4" />
|
||||
</Button>
|
||||
<Button type="button" variant="outline" class="text-destructive hover:text-destructive" :disabled="mcpInstalling || mcpUninstalling || !mcpStatus.npm_available" @click="uninstallMcp">
|
||||
<Button type="button" variant="outline" class="text-destructive hover:text-destructive" :disabled="mcpInstalling || mcpUninstalling" @click="uninstallMcp">
|
||||
<Loader2 v-if="mcpUninstalling" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Trash2 v-else class="mr-2 h-4 w-4" />
|
||||
{{ mcpUninstalling ? t("settings.mcpUninstalling") : t("settings.mcpUninstallButton") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isWeb && mcpStatus?.installation_source !== 'npm' && mcpStatus?.npm_installed" class="rounded-md border border-amber-500/30 bg-amber-500/5 p-3">
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="min-w-0 flex-1 text-xs text-amber-800 dark:text-amber-200">
|
||||
{{ t("settings.mcpNpmFallbackHint") }}
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="sm" :disabled="mcpInstalling || mcpUninstalling" @click="uninstallNpmMcpFallback">
|
||||
<Loader2 v-if="mcpUninstalling" class="mr-2 h-4 w-4 animate-spin" />
|
||||
<Trash2 v-else class="mr-2 h-4 w-4" />
|
||||
{{ t("settings.mcpRemoveNpmFallbackButton") }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -10378,7 +10422,7 @@ LIMIT 100;</pre
|
||||
|
||||
<div v-if="mcpTransportTab === 'stdio'" class="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<Terminal class="h-3.5 w-3.5" />
|
||||
<span>{{ t("settings.mcpDetectionTiming") }} {{ t("settings.mcpNpmBoundary") }}</span>
|
||||
<span>{{ t("settings.mcpDetectionTiming") }} {{ t("settings.mcpNativeManagementBoundary") }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs>
|
||||
|
||||
+1
-1
@@ -615,7 +615,7 @@ describe("UpdateDialog aggregate update center", () => {
|
||||
await mountDialog(0, {}, undefined, {
|
||||
driverUpdates: [{ db_type: "mysql", label: "MySQL", version: "9.0.0", installed_version: "8.0.0", update_available: true }],
|
||||
jdbcUpdate: { installed: true, version: "0.1.0", latest_version: "0.2.0", update_available: true, compatible: true, path: "/tmp/jdbc" },
|
||||
mcpUpdate: { installed: true, npm_available: true, current_version: "1.0.0", latest_version: "1.1.0", update_available: true },
|
||||
mcpUpdate: { installed: true, installation_source: "npm", npm_available: true, npm_installed: true, current_version: "1.0.0", latest_version: "1.1.0", update_available: true },
|
||||
pluginUpdates: [
|
||||
{
|
||||
key: "official:example",
|
||||
|
||||
@@ -33,7 +33,9 @@ vi.mock("@/lib/plugins/pluginMarketplace", async (importOriginal) => ({ ...(awai
|
||||
|
||||
const mcpStatus = {
|
||||
installed: true,
|
||||
installation_source: "npm",
|
||||
npm_available: true,
|
||||
npm_installed: true,
|
||||
current_version: "1.0.0",
|
||||
latest_version: "1.1.0",
|
||||
update_available: true,
|
||||
|
||||
@@ -32,7 +32,9 @@ function deferred<T>(): Deferred<T> {
|
||||
function makeStatus(update_available: boolean, overrides: Partial<McpServerStatus> = {}): McpServerStatus {
|
||||
return {
|
||||
installed: true,
|
||||
installation_source: "npm",
|
||||
npm_available: true,
|
||||
npm_installed: true,
|
||||
node_path: null,
|
||||
node_version: null,
|
||||
current_version: "1.0.0",
|
||||
@@ -44,6 +46,7 @@ function makeStatus(update_available: boolean, overrides: Partial<McpServerStatu
|
||||
data_dir: null,
|
||||
install_command: "",
|
||||
update_command: "",
|
||||
uninstall_command: "",
|
||||
error: null,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
@@ -8601,6 +8601,12 @@ export default {
|
||||
mcpUpdateCommand: "Upgrade command",
|
||||
mcpUninstallCommand: "Uninstall command",
|
||||
mcpInstallButton: "Install",
|
||||
mcpSwitchToNativeButton: "Switch to native (recommended)",
|
||||
mcpNativeMigrationHint: "Your npm installation remains usable. Install the standalone native binary, then update external MCP client configurations to the generated absolute path; DBX will not remove npm automatically.",
|
||||
mcpNpmFallbackHint: "The previous npm installation remains available as a fallback. First, use the matching Agent configuration below to replace the launch command with the displayed native absolute path. Remove the fallback only after every external MCP client has been migrated.",
|
||||
mcpRemoveNpmFallbackButton: "Remove npm fallback",
|
||||
mcpRemoveNpmFallbackConfirm: "Remove the retained npm DBX MCP installation? First confirm that every external MCP client has been updated to the displayed native absolute path using its matching Agent configuration below.",
|
||||
mcpRemoveNpmFallbackFailed: "Failed to remove npm fallback: {error}",
|
||||
mcpUpdateButton: "Upgrade",
|
||||
mcpUpToDate: "Up to date",
|
||||
mcpInstalling: "Installing...",
|
||||
@@ -8689,6 +8695,7 @@ export default {
|
||||
mcpPolicyLoadFailed: "Failed to load the MCP access policy: {error}",
|
||||
mcpDetectionTiming: "DBX checks automatically when this page opens; use Check again to refresh.",
|
||||
mcpNpmBoundary: "DBX checks MCP status; installation, upgrades, and uninstallation run through the detected npm, pnpm, or bun runtime.",
|
||||
mcpNativeManagementBoundary: "DBX installs and manages the standalone native server by default. Existing npm installations remain available as a fallback and are never removed automatically.",
|
||||
mcpRefresh: "Check again",
|
||||
mcpGuide: "MCP guide",
|
||||
mcpAuthTitle: "MCP authorization",
|
||||
|
||||
@@ -8574,6 +8574,12 @@ export default withEnglishFallback({
|
||||
mcpUpdateCommand: "升级命令",
|
||||
mcpUninstallCommand: "卸载命令",
|
||||
mcpInstallButton: "安装",
|
||||
mcpSwitchToNativeButton: "切换到原生安装(推荐)",
|
||||
mcpNativeMigrationHint: "现有 npm 安装会继续保留。安装独立原生二进制后,请把外部 MCP 客户端配置更新为页面生成的绝对路径;DBX 不会自动卸载 npm 包。",
|
||||
mcpNpmFallbackHint: "原 npm 安装仍作为兜底保留。请先按照下方对应 Agent 的配置,将启动命令改为所示的原生绝对路径;确认所有外部 MCP 客户端均已完成迁移后,再选择清理。",
|
||||
mcpRemoveNpmFallbackButton: "清理 npm 兜底",
|
||||
mcpRemoveNpmFallbackConfirm: "确定移除保留的 npm DBX MCP 安装吗?请先确认所有外部 MCP 客户端都已按照下方对应 Agent 的配置,改用所示的原生绝对路径。",
|
||||
mcpRemoveNpmFallbackFailed: "清理 npm 兜底失败:{error}",
|
||||
mcpUpdateButton: "升级",
|
||||
mcpUpToDate: "已是最新",
|
||||
mcpInstalling: "安装中...",
|
||||
@@ -8662,6 +8668,7 @@ export default withEnglishFallback({
|
||||
mcpPolicyLoadFailed: "加载 MCP 访问策略失败:{error}",
|
||||
mcpDetectionTiming: "打开此页时会自动检测,点击重新检查可刷新。",
|
||||
mcpNpmBoundary: "DBX 负责检查 MCP 状态;安装、升级和卸载由检测到的 npm、pnpm 或 bun 运行环境执行。",
|
||||
mcpNativeManagementBoundary: "DBX 默认安装并管理独立原生服务;现有 npm 安装继续作为兜底,且不会被自动卸载。",
|
||||
mcpRefresh: "重新检查",
|
||||
mcpGuide: "MCP 指南",
|
||||
mcpAuthTitle: "MCP 授权设置",
|
||||
|
||||
@@ -7213,6 +7213,12 @@ export default withEnglishFallback({
|
||||
mcpInstallCommand: "安裝命令",
|
||||
mcpUpdateCommand: "升級命令",
|
||||
mcpInstallButton: "安裝",
|
||||
mcpSwitchToNativeButton: "切換到原生安裝(推薦)",
|
||||
mcpNativeMigrationHint: "現有 npm 安裝會繼續保留。安裝獨立原生二進位後,請將外部 MCP 用戶端設定更新為頁面產生的絕對路徑;DBX 不會自動移除 npm 套件。",
|
||||
mcpNpmFallbackHint: "原 npm 安裝仍作為備援保留。請先依照下方對應 Agent 的設定,將啟動命令改為所示的原生絕對路徑;確認所有外部 MCP 用戶端均已完成遷移後,再選擇清理。",
|
||||
mcpRemoveNpmFallbackButton: "清理 npm 備援",
|
||||
mcpRemoveNpmFallbackConfirm: "確定移除保留的 npm DBX MCP 安裝嗎?請先確認所有外部 MCP 用戶端都已依照下方對應 Agent 的設定,改用所示的原生絕對路徑。",
|
||||
mcpRemoveNpmFallbackFailed: "清理 npm 備援失敗:{error}",
|
||||
mcpUpdateButton: "升級",
|
||||
mcpUpToDate: "已是最新",
|
||||
mcpInstalling: "安裝中...",
|
||||
@@ -7293,6 +7299,7 @@ export default withEnglishFallback({
|
||||
mcpPolicyLoadFailed: "載入 MCP 存取策略失敗:{error}",
|
||||
mcpDetectionTiming: "打開此頁時會自動檢測,點擊重新檢查可刷新。",
|
||||
mcpNpmBoundary: "DBX 只檢測和提示 MCP 狀態;安裝與升級仍由 npm、pnpm 或 bun 完成。",
|
||||
mcpNativeManagementBoundary: "DBX 預設安裝並管理獨立原生服務;現有 npm 安裝繼續作為備援,且不會被自動移除。",
|
||||
mcpRefresh: "重新檢查",
|
||||
mcpGuide: "MCP 指南",
|
||||
mcpAuthTitle: "MCP 授權設定",
|
||||
|
||||
@@ -1,7 +1,35 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildMcpCherryStudioConfig, buildMcpCodexConfig, buildMcpDeepSeekHarnessConfig, buildMcpJsonConfig, buildMcpOpenCodeConfig, buildMcpPiConfig, buildMcpQoderConfig, buildMcpTraeConfig, buildMcpVsCodeConfig, buildMcpWorkBuddyConfig, mcpWebBackendUrl } from "@/lib/mcp/mcpConfigTemplates";
|
||||
import {
|
||||
buildMcpCherryStudioConfig,
|
||||
buildMcpCodexConfig,
|
||||
buildMcpDeepSeekHarnessConfig,
|
||||
buildMcpJsonConfig,
|
||||
buildMcpOpenCodeConfig,
|
||||
buildMcpPiConfig,
|
||||
buildMcpQoderConfig,
|
||||
buildMcpTraeConfig,
|
||||
buildMcpVsCodeConfig,
|
||||
buildMcpWorkBuddyConfig,
|
||||
mcpWebBackendUrl,
|
||||
preferMcpNativeLaunch,
|
||||
} from "@/lib/mcp/mcpConfigTemplates";
|
||||
|
||||
describe("MCP config templates", () => {
|
||||
it.each(["/Users/DBX User/.dbx/bin/dbx-mcp", "/home/dbx/.dbx/bin/dbx-mcp", "C:\\Users\\DBX User\\.dbx\\bin\\dbx-mcp.exe"])("prefers native launch for every client: %s", (binary) => {
|
||||
const shim = { command: "node", args: ["/old/shim.js"], env: { DBX_DATA_DIR: "/custom/data" } };
|
||||
const native = preferMcpNativeLaunch(shim, binary);
|
||||
expect(native).toEqual({ command: binary, env: shim.env });
|
||||
for (const builder of [buildMcpJsonConfig, buildMcpTraeConfig, buildMcpQoderConfig, buildMcpVsCodeConfig, buildMcpCherryStudioConfig, buildMcpCodexConfig, buildMcpDeepSeekHarnessConfig, buildMcpOpenCodeConfig, buildMcpPiConfig, buildMcpWorkBuddyConfig]) {
|
||||
const result = builder(native);
|
||||
expect(result).toContain(JSON.stringify(binary));
|
||||
expect(result).toContain("DBX_DATA_DIR");
|
||||
expect(result).not.toContain("shim.js");
|
||||
}
|
||||
expect(preferMcpNativeLaunch(shim)).toBe(shim);
|
||||
expect(preferMcpNativeLaunch(shim, "")).toBe(shim);
|
||||
expect(preferMcpNativeLaunch()).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds the standard mcpServers JSON used by Claude, Cursor, TRAE, and Windsurf", () => {
|
||||
const config = JSON.parse(buildMcpJsonConfig());
|
||||
|
||||
|
||||
@@ -6,6 +6,20 @@ const tauriBackendSource = readFileSync(new URL("../../../lib/backend/tauri.ts",
|
||||
const httpBackendSource = readFileSync(new URL("../../../lib/backend/http.ts", import.meta.url), "utf8");
|
||||
|
||||
describe("MCP server management", () => {
|
||||
it("makes native installation the default while preserving npm as a migration fallback", () => {
|
||||
expect(settingsDialogSource).toContain('mcpStatus.value?.installation_source === "npm"');
|
||||
expect(settingsDialogSource).toContain('t("settings.mcpSwitchToNativeButton")');
|
||||
expect(settingsDialogSource).toContain('t("settings.mcpNativeMigrationHint")');
|
||||
expect(settingsDialogSource).toContain("await installNativeMcpServer()");
|
||||
expect(settingsDialogSource).toContain("await uninstallNpmMcpServer()");
|
||||
expect(settingsDialogSource).toContain("mcpStatus?.installation_source !== 'npm' && mcpStatus?.npm_installed");
|
||||
expect(settingsDialogSource).toContain(':disabled="mcpInstallDisabled"');
|
||||
expect(settingsDialogSource).not.toContain("!mcpStatus?.npm_available");
|
||||
expect(tauriBackendSource).toContain('installation_source: "native" | "homebrew" | "npm" | null');
|
||||
expect(tauriBackendSource).toContain('invoke("install_native_mcp_server")');
|
||||
expect(tauriBackendSource).toContain('invoke("uninstall_npm_mcp_server")');
|
||||
});
|
||||
|
||||
it("exposes a confirmed uninstall action only for an installed desktop server", () => {
|
||||
expect(settingsDialogSource).toContain('v-if="!isWeb && mcpStatus?.installed"');
|
||||
expect(settingsDialogSource).toContain('window.confirm(t("settings.mcpUninstallConfirm"))');
|
||||
|
||||
@@ -921,7 +921,9 @@ export const deleteHistoryEntry = forward("deleteHistoryEntry");
|
||||
// Updates
|
||||
export const checkMcpServerStatus = forward("checkMcpServerStatus");
|
||||
export const installMcpServer = forward("installMcpServer");
|
||||
export const installNativeMcpServer = forward("installNativeMcpServer");
|
||||
export const uninstallMcpServer = forward("uninstallMcpServer");
|
||||
export const uninstallNpmMcpServer = forward("uninstallNpmMcpServer");
|
||||
export const loadMcpHttpServerSettings = forward("loadMcpHttpServerSettings");
|
||||
export const saveMcpHttpServerSettings = forward("saveMcpHttpServerSettings");
|
||||
export const mcpHttpServerStatus = forward("mcpHttpServerStatus");
|
||||
|
||||
@@ -5200,7 +5200,9 @@ export async function fetchChangelog(lang?: string): Promise<import("@/lib/app/c
|
||||
export async function checkMcpServerStatus(): Promise<import("@/lib/backend/tauri").McpServerStatus> {
|
||||
return {
|
||||
installed: false,
|
||||
installation_source: null,
|
||||
npm_available: false,
|
||||
npm_installed: false,
|
||||
node_path: null,
|
||||
node_version: null,
|
||||
current_version: null,
|
||||
@@ -5221,10 +5223,18 @@ export async function installMcpServer(): Promise<string> {
|
||||
throw new Error("MCP Server installation is only available in the desktop app.");
|
||||
}
|
||||
|
||||
export async function installNativeMcpServer(): Promise<string> {
|
||||
throw new Error("Native MCP Server installation is only available in the desktop app.");
|
||||
}
|
||||
|
||||
export async function uninstallMcpServer(): Promise<string> {
|
||||
throw new Error("MCP Server uninstallation is only available in the desktop app.");
|
||||
}
|
||||
|
||||
export async function uninstallNpmMcpServer(): Promise<string> {
|
||||
throw new Error("MCP Server npm fallback removal is only available in the desktop app.");
|
||||
}
|
||||
|
||||
export async function getSystemProxyUrl(): Promise<string | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -2968,7 +2968,9 @@ export interface UpdateDownloadProgress {
|
||||
|
||||
export interface McpServerStatus {
|
||||
installed: boolean;
|
||||
installation_source: "native" | "homebrew" | "npm" | null;
|
||||
npm_available: boolean;
|
||||
npm_installed: boolean;
|
||||
node_path: string | null;
|
||||
node_version: string | null;
|
||||
current_version: string | null;
|
||||
@@ -2992,10 +2994,18 @@ export async function installMcpServer(): Promise<string> {
|
||||
return invoke("install_mcp_server");
|
||||
}
|
||||
|
||||
export async function installNativeMcpServer(): Promise<string> {
|
||||
return invoke("install_native_mcp_server");
|
||||
}
|
||||
|
||||
export async function uninstallMcpServer(): Promise<string> {
|
||||
return invoke("uninstall_mcp_server");
|
||||
}
|
||||
|
||||
export async function uninstallNpmMcpServer(): Promise<string> {
|
||||
return invoke("uninstall_npm_mcp_server");
|
||||
}
|
||||
|
||||
export async function checkForUpdates(locale?: string, source?: UpdateDownloadSource): Promise<UpdateInfo> {
|
||||
return invoke("check_for_updates", { locale, source });
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ const DEFAULT_MCP_LAUNCH_CONFIG: McpLaunchConfig = {
|
||||
command: "dbx-mcp-server",
|
||||
};
|
||||
|
||||
export function preferMcpNativeLaunch(config?: McpLaunchConfig, nativeBinPath?: string | null): McpLaunchConfig | undefined {
|
||||
return nativeBinPath ? { command: nativeBinPath, env: config?.env } : config;
|
||||
}
|
||||
|
||||
function launchConfig(config?: McpLaunchConfig): McpLaunchConfig {
|
||||
return config ?? DEFAULT_MCP_LAUNCH_CONFIG;
|
||||
}
|
||||
@@ -41,7 +45,7 @@ export function buildMcpJsonConfig(config?: McpLaunchConfig): string {
|
||||
}
|
||||
|
||||
export function buildMcpTraeConfig(config?: McpLaunchConfig, nativeBinPath?: string): string {
|
||||
return buildMcpJsonConfig(nativeBinPath ? { command: nativeBinPath, env: config?.env } : config);
|
||||
return buildMcpJsonConfig(preferMcpNativeLaunch(config, nativeBinPath));
|
||||
}
|
||||
|
||||
export function buildMcpQoderConfig(config?: McpLaunchConfig, nativeBinPath?: string): string {
|
||||
|
||||
@@ -81,7 +81,9 @@ export function previewJdbcUpdate(): JdbcPluginStatus {
|
||||
export function previewMcpUpdate(): McpServerStatus {
|
||||
return {
|
||||
installed: true,
|
||||
installation_source: "npm",
|
||||
npm_available: true,
|
||||
npm_installed: true,
|
||||
node_path: "/preview/bin/node",
|
||||
node_version: "v22.18.0",
|
||||
current_version: "0.4.88",
|
||||
|
||||
@@ -8,6 +8,10 @@ use rmcp::ServiceExt;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if std::env::args_os().nth(1).is_some_and(|argument| argument == "--version") {
|
||||
println!("dbx-mcp {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
let runtime = RuntimeConfig::from_environment_and_args()?;
|
||||
let backend: Arc<dyn DbxBackend> = if let Ok(base_url) = std::env::var("DBX_WEB_URL") {
|
||||
Arc::new(
|
||||
|
||||
@@ -2,11 +2,16 @@ use std::{collections::HashMap, sync::Arc, time::Duration, time::Instant};
|
||||
|
||||
use rmcp::{
|
||||
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
|
||||
model::{CallToolResult, ContentBlock, Implementation, ServerCapabilities, ServerInfo},
|
||||
model::{
|
||||
CallToolResult, ContentBlock, ErrorData, Implementation, ListResourceTemplatesResult, ListResourcesResult,
|
||||
ReadResourceRequestParams, ReadResourceResult, Resource, ResourceContents, ResourceTemplate,
|
||||
ServerCapabilities, ServerInfo,
|
||||
},
|
||||
schemars, tool, tool_handler, tool_router, ServerHandler,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use url::Url;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::backend::{format_query_result, new_connection_config, parse_database_type, ConnectionSummary, DbxBackend};
|
||||
@@ -36,6 +41,19 @@ use dbx_core::{
|
||||
storage::{McpDatabaseScope, McpGlobalPolicy},
|
||||
};
|
||||
|
||||
const CONNECTIONS_RESOURCE_URI: &str = "dbx://connections";
|
||||
const DATABASES_RESOURCE_TEMPLATE: &str = "dbx://connections/{connection_id}/databases";
|
||||
const TABLES_RESOURCE_TEMPLATE: &str = "dbx://connections/{connection_id}/tables{?database,schema}";
|
||||
const TABLE_SCHEMA_RESOURCE_TEMPLATE: &str = "dbx://connections/{connection_id}/table-schema{?database,schema,table}";
|
||||
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
enum DbxResourceRequest {
|
||||
Connections,
|
||||
Databases { connection_id: String },
|
||||
Tables { connection_id: String, database: Option<String>, schema: Option<String> },
|
||||
TableSchema { connection_id: String, database: Option<String>, schema: Option<String>, table: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, schemars::JsonSchema)]
|
||||
pub struct ListConnectionsRequest {}
|
||||
|
||||
@@ -2847,11 +2865,101 @@ impl DbxMcpServer {
|
||||
#[tool_handler(router = self.tool_router)]
|
||||
impl ServerHandler for DbxMcpServer {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
|
||||
ServerInfo::new(ServerCapabilities::builder().enable_tools().enable_resources().build())
|
||||
.with_server_info(Implementation::new("dbx", env!("CARGO_PKG_VERSION")))
|
||||
.with_instructions("Use DBX connections to inspect schemas and query databases safely.")
|
||||
}
|
||||
|
||||
async fn list_resources(
|
||||
&self,
|
||||
_request: Option<rmcp::model::PaginatedRequestParams>,
|
||||
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
|
||||
) -> Result<ListResourcesResult, ErrorData> {
|
||||
let tools = self.policy_filtered_tools().await;
|
||||
let resources = tools
|
||||
.iter()
|
||||
.any(|tool| tool.name.as_ref() == "dbx_list_connections")
|
||||
.then(|| {
|
||||
Resource::new(CONNECTIONS_RESOURCE_URI, "dbx_connections")
|
||||
.with_title("DBX connections")
|
||||
.with_description("Database connections visible to the current DBX MCP scope")
|
||||
.with_mime_type("text/markdown")
|
||||
})
|
||||
.into_iter()
|
||||
.collect();
|
||||
Ok(ListResourcesResult { resources, meta: None, next_cursor: None })
|
||||
}
|
||||
|
||||
async fn list_resource_templates(
|
||||
&self,
|
||||
_request: Option<rmcp::model::PaginatedRequestParams>,
|
||||
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
|
||||
) -> Result<ListResourceTemplatesResult, ErrorData> {
|
||||
let tools = self.policy_filtered_tools().await;
|
||||
let has_tool = |name: &str| tools.iter().any(|tool| tool.name.as_ref() == name);
|
||||
let mut resource_templates = Vec::new();
|
||||
if has_tool("dbx_list_databases") {
|
||||
resource_templates.push(
|
||||
ResourceTemplate::new(DATABASES_RESOURCE_TEMPLATE, "dbx_connection_databases")
|
||||
.with_title("DBX connection databases")
|
||||
.with_description("Databases visible through a DBX connection")
|
||||
.with_mime_type("text/markdown"),
|
||||
);
|
||||
}
|
||||
if has_tool("dbx_list_tables") {
|
||||
resource_templates.push(
|
||||
ResourceTemplate::new(TABLES_RESOURCE_TEMPLATE, "dbx_connection_tables")
|
||||
.with_title("DBX connection tables")
|
||||
.with_description("Tables and views visible through a DBX connection and optional database/schema")
|
||||
.with_mime_type("text/markdown"),
|
||||
);
|
||||
}
|
||||
if has_tool("dbx_describe_table") {
|
||||
resource_templates.push(
|
||||
ResourceTemplate::new(TABLE_SCHEMA_RESOURCE_TEMPLATE, "dbx_table_schema")
|
||||
.with_title("DBX table schema")
|
||||
.with_description("Column definitions for a table visible through a DBX connection")
|
||||
.with_mime_type("text/markdown"),
|
||||
);
|
||||
}
|
||||
Ok(ListResourceTemplatesResult { resource_templates, meta: None, next_cursor: None })
|
||||
}
|
||||
|
||||
async fn read_resource(
|
||||
&self,
|
||||
request: ReadResourceRequestParams,
|
||||
_context: rmcp::service::RequestContext<rmcp::service::RoleServer>,
|
||||
) -> Result<ReadResourceResult, ErrorData> {
|
||||
let uri = request.uri;
|
||||
let result = match parse_dbx_resource_uri(&uri)? {
|
||||
DbxResourceRequest::Connections => self.list_connections(Parameters(ListConnectionsRequest {})).await,
|
||||
DbxResourceRequest::Databases { connection_id } => {
|
||||
self.list_databases(Parameters(ListDatabasesRequest {
|
||||
selector: ConnectionSelector { connection_id: Some(connection_id), connection_name: None },
|
||||
}))
|
||||
.await
|
||||
}
|
||||
DbxResourceRequest::Tables { connection_id, database, schema } => {
|
||||
self.list_tables(Parameters(ListTablesRequest {
|
||||
selector: ConnectionSelector { connection_id: Some(connection_id), connection_name: None },
|
||||
database,
|
||||
schema,
|
||||
}))
|
||||
.await
|
||||
}
|
||||
DbxResourceRequest::TableSchema { connection_id, database, schema, table } => {
|
||||
self.describe_table(Parameters(DescribeTableRequest {
|
||||
selector: ConnectionSelector { connection_id: Some(connection_id), connection_name: None },
|
||||
table,
|
||||
database,
|
||||
schema,
|
||||
}))
|
||||
.await
|
||||
}
|
||||
};
|
||||
resource_result_from_tool(uri, result)
|
||||
}
|
||||
|
||||
/// Hide tools the global policy disallows from the advertised list, the
|
||||
/// same way scope-based route disabling does. Per-call enforcement stays
|
||||
/// in `ensure_tool_allowed`; this only stops clients (especially LLMs)
|
||||
@@ -2868,6 +2976,102 @@ impl ServerHandler for DbxMcpServer {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_dbx_resource_uri(uri: &str) -> Result<DbxResourceRequest, ErrorData> {
|
||||
let parsed = Url::parse(uri).map_err(|_| ErrorData::invalid_params("Invalid DBX resource URI.", None))?;
|
||||
if parsed.scheme() != "dbx"
|
||||
|| parsed.host_str() != Some("connections")
|
||||
|| !parsed.username().is_empty()
|
||||
|| parsed.password().is_some()
|
||||
|| parsed.port().is_some()
|
||||
|| parsed.fragment().is_some()
|
||||
{
|
||||
return Err(ErrorData::resource_not_found(format!("Unknown DBX resource: {uri}"), None));
|
||||
}
|
||||
|
||||
let path = parsed.path().trim_matches('/');
|
||||
let segments = if path.is_empty() { Vec::new() } else { path.split('/').collect::<Vec<_>>() };
|
||||
let mut query = HashMap::new();
|
||||
for (key, value) in parsed.query_pairs() {
|
||||
let key = key.into_owned();
|
||||
if query.insert(key.clone(), value.into_owned()).is_some() {
|
||||
return Err(ErrorData::invalid_params(format!("Duplicate DBX resource parameter: {key}"), None));
|
||||
}
|
||||
}
|
||||
|
||||
match segments.as_slice() {
|
||||
[] => {
|
||||
reject_resource_parameters(&query, &[])?;
|
||||
Ok(DbxResourceRequest::Connections)
|
||||
}
|
||||
[connection_id, "databases"] => {
|
||||
reject_resource_parameters(&query, &[])?;
|
||||
Ok(DbxResourceRequest::Databases {
|
||||
connection_id: required_resource_value("connection_id", connection_id)?,
|
||||
})
|
||||
}
|
||||
[connection_id, "tables"] => {
|
||||
reject_resource_parameters(&query, &["database", "schema"])?;
|
||||
Ok(DbxResourceRequest::Tables {
|
||||
connection_id: required_resource_value("connection_id", connection_id)?,
|
||||
database: optional_resource_parameter(&query, "database"),
|
||||
schema: optional_resource_parameter(&query, "schema"),
|
||||
})
|
||||
}
|
||||
[connection_id, "table-schema"] => {
|
||||
reject_resource_parameters(&query, &["database", "schema", "table"])?;
|
||||
Ok(DbxResourceRequest::TableSchema {
|
||||
connection_id: required_resource_value("connection_id", connection_id)?,
|
||||
database: optional_resource_parameter(&query, "database"),
|
||||
schema: optional_resource_parameter(&query, "schema"),
|
||||
table: required_resource_parameter(&query, "table")?,
|
||||
})
|
||||
}
|
||||
_ => Err(ErrorData::resource_not_found(format!("Unknown DBX resource: {uri}"), None)),
|
||||
}
|
||||
}
|
||||
|
||||
fn reject_resource_parameters(parameters: &HashMap<String, String>, allowed: &[&str]) -> Result<(), ErrorData> {
|
||||
if let Some(name) = parameters.keys().find(|name| !allowed.contains(&name.as_str())) {
|
||||
return Err(ErrorData::invalid_params(format!("Unknown DBX resource parameter: {name}"), None));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn optional_resource_parameter(parameters: &HashMap<String, String>, name: &str) -> Option<String> {
|
||||
parameters.get(name).map(|value| value.trim()).filter(|value| !value.is_empty()).map(str::to_string)
|
||||
}
|
||||
|
||||
fn required_resource_parameter(parameters: &HashMap<String, String>, name: &str) -> Result<String, ErrorData> {
|
||||
optional_resource_parameter(parameters, name)
|
||||
.ok_or_else(|| ErrorData::invalid_params(format!("Missing DBX resource parameter: {name}"), None))
|
||||
}
|
||||
|
||||
fn required_resource_value(name: &str, value: &str) -> Result<String, ErrorData> {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
Err(ErrorData::invalid_params(format!("Missing DBX resource parameter: {name}"), None))
|
||||
} else {
|
||||
Ok(value.to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn resource_result_from_tool(uri: String, result: CallToolResult) -> Result<ReadResourceResult, ErrorData> {
|
||||
let output = result
|
||||
.content
|
||||
.iter()
|
||||
.filter_map(|content| content.as_text().map(|text| text.text.as_str()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if result.is_error == Some(true) {
|
||||
let message = if output.is_empty() { "DBX resource read failed.".to_string() } else { output };
|
||||
return Err(ErrorData::invalid_params(message, Some(json!({ "uri": uri }))));
|
||||
}
|
||||
if output.is_empty() {
|
||||
return Err(ErrorData::internal_error("DBX resource returned no text content.", Some(json!({ "uri": uri }))));
|
||||
}
|
||||
Ok(ReadResourceResult::new(vec![ResourceContents::text(output, uri).with_mime_type("text/markdown")]))
|
||||
}
|
||||
|
||||
fn text(value: impl Into<String>) -> CallToolResult {
|
||||
CallToolResult::success(vec![ContentBlock::text(value)])
|
||||
}
|
||||
@@ -4014,6 +4218,34 @@ mod tests {
|
||||
};
|
||||
use std::{collections::HashSet, sync::atomic::AtomicUsize, time::Duration};
|
||||
|
||||
#[test]
|
||||
fn parses_dbx_resource_uris_and_decodes_query_values() {
|
||||
assert_eq!(parse_dbx_resource_uri(CONNECTIONS_RESOURCE_URI).unwrap(), DbxResourceRequest::Connections);
|
||||
assert_eq!(
|
||||
parse_dbx_resource_uri("dbx://connections/connection-1/databases").unwrap(),
|
||||
DbxResourceRequest::Databases { connection_id: "connection-1".to_string() }
|
||||
);
|
||||
assert_eq!(
|
||||
parse_dbx_resource_uri(
|
||||
"dbx://connections/connection-1/table-schema?database=sales%20data&schema=public&table=order%20items"
|
||||
)
|
||||
.unwrap(),
|
||||
DbxResourceRequest::TableSchema {
|
||||
connection_id: "connection-1".to_string(),
|
||||
database: Some("sales data".to_string()),
|
||||
schema: Some("public".to_string()),
|
||||
table: "order items".to_string(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_dbx_resource_uris() {
|
||||
assert!(parse_dbx_resource_uri("dbx://connections/connection-1/table-schema").is_err());
|
||||
assert!(parse_dbx_resource_uri("dbx://connections/connection-1/tables?unknown=value").is_err());
|
||||
assert!(parse_dbx_resource_uri("file:///tmp/connections").is_err());
|
||||
}
|
||||
|
||||
struct FakeTransactionIo {
|
||||
in_transaction: bool,
|
||||
executed_sql: Arc<std::sync::Mutex<Vec<String>>>,
|
||||
|
||||
@@ -6,7 +6,10 @@ use dbx_core::{
|
||||
storage::McpGlobalPolicy,
|
||||
};
|
||||
use dbx_mcp::{DbxBackend, DbxMcpServer, McpScope};
|
||||
use rmcp::{model::CallToolRequestParams, ServiceExt};
|
||||
use rmcp::{
|
||||
model::{CallToolRequestParams, ReadResourceRequestParams, ResourceContents},
|
||||
ServiceExt,
|
||||
};
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
struct EmptyBackend;
|
||||
@@ -72,6 +75,42 @@ impl DbxBackend for PolicyBackend {
|
||||
Ok(self.connections.clone())
|
||||
}
|
||||
|
||||
async fn list_databases(&self, _connection: &ConnectionConfig) -> Result<Vec<String>, String> {
|
||||
Ok(vec!["analytics".to_string(), "reporting".to_string()])
|
||||
}
|
||||
|
||||
async fn list_tables(
|
||||
&self,
|
||||
_connection: &ConnectionConfig,
|
||||
_database: &str,
|
||||
_schema: &str,
|
||||
) -> Result<Vec<dbx_core::db::TableInfo>, String> {
|
||||
Ok(vec![dbx_core::db::TableInfo {
|
||||
name: "orders".to_string(),
|
||||
table_type: "TABLE".to_string(),
|
||||
valid: Some(true),
|
||||
comment: Some("Customer orders".to_string()),
|
||||
parent_schema: None,
|
||||
parent_name: None,
|
||||
}])
|
||||
}
|
||||
|
||||
async fn get_columns(
|
||||
&self,
|
||||
_connection: &ConnectionConfig,
|
||||
_database: &str,
|
||||
_schema: &str,
|
||||
_table: &str,
|
||||
) -> Result<Vec<dbx_core::db::ColumnInfo>, String> {
|
||||
Ok(vec![dbx_core::db::ColumnInfo {
|
||||
name: "id".to_string(),
|
||||
data_type: "INTEGER".to_string(),
|
||||
is_nullable: false,
|
||||
is_primary_key: true,
|
||||
..Default::default()
|
||||
}])
|
||||
}
|
||||
|
||||
async fn load_connection_group_paths(&self) -> Result<HashMap<String, Vec<String>>, String> {
|
||||
self.group_paths.clone()
|
||||
}
|
||||
@@ -354,6 +393,28 @@ async fn initializes_lists_tools_and_calls_a_tool() {
|
||||
#[cfg(feature = "mq-admin")]
|
||||
assert!(names.contains(&"dbx_send_message"));
|
||||
|
||||
let server_info = client.peer_info().expect("server info");
|
||||
assert!(server_info.capabilities.resources.is_some());
|
||||
|
||||
let resources = client.peer().list_resources(None).await.expect("list resources");
|
||||
assert_eq!(resources.resources.len(), 1);
|
||||
assert_eq!(resources.resources[0].uri, "dbx://connections");
|
||||
|
||||
let templates = client.peer().list_resource_templates(None).await.expect("list resource templates");
|
||||
let template_names = templates.resource_templates.iter().map(|template| template.name.as_str()).collect::<Vec<_>>();
|
||||
assert_eq!(template_names, vec!["dbx_connection_databases", "dbx_connection_tables", "dbx_table_schema"]);
|
||||
|
||||
let resource = client
|
||||
.peer()
|
||||
.read_resource(ReadResourceRequestParams::new("dbx://connections"))
|
||||
.await
|
||||
.expect("read connections resource");
|
||||
let ResourceContents::TextResourceContents { text, mime_type, .. } = &resource.contents[0] else {
|
||||
panic!("connections resource should be text");
|
||||
};
|
||||
assert_eq!(mime_type.as_deref(), Some("text/markdown"));
|
||||
assert_eq!(text, "No connections configured in DBX.");
|
||||
|
||||
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.");
|
||||
@@ -362,6 +423,76 @@ async fn initializes_lists_tools_and_calls_a_tool() {
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn reads_database_metadata_through_resource_templates() {
|
||||
let backend = PolicyBackend {
|
||||
policy: McpGlobalPolicy::default(),
|
||||
connections: vec![test_connection("local", "local-db")],
|
||||
group_paths: Ok(HashMap::new()),
|
||||
};
|
||||
let (server_transport, client_transport) = tokio::io::duplex(16 * 1024);
|
||||
let server = DbxMcpServer::with_runtime_options(Arc::new(backend), 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");
|
||||
|
||||
for (uri, expected) in [
|
||||
("dbx://connections/local/databases", "analytics"),
|
||||
("dbx://connections/local/tables?database=analytics", "orders (TABLE)"),
|
||||
("dbx://connections/local/table-schema?database=analytics&table=orders", "id (PK)"),
|
||||
] {
|
||||
let result = client
|
||||
.peer()
|
||||
.read_resource(ReadResourceRequestParams::new(uri))
|
||||
.await
|
||||
.unwrap_or_else(|error| panic!("read {uri}: {error}"));
|
||||
let ResourceContents::TextResourceContents { text, mime_type, .. } = &result.contents[0] else {
|
||||
panic!("{uri} should return text");
|
||||
};
|
||||
assert_eq!(mime_type.as_deref(), Some("text/markdown"));
|
||||
assert!(text.contains(expected), "{uri}: {text}");
|
||||
}
|
||||
|
||||
let invalid = client
|
||||
.peer()
|
||||
.read_resource(ReadResourceRequestParams::new("dbx://connections/local/table-schema?database=analytics"))
|
||||
.await;
|
||||
assert!(invalid.is_err());
|
||||
|
||||
client.cancel().await.expect("close MCP client");
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resource_catalog_follows_the_tool_allowlist() {
|
||||
let backend = PolicyBackend {
|
||||
policy: McpGlobalPolicy {
|
||||
allowed_tool_names: Some(vec!["dbx_list_connections".to_string()]),
|
||||
..Default::default()
|
||||
},
|
||||
connections: vec![test_connection("local", "local-db")],
|
||||
group_paths: Ok(HashMap::new()),
|
||||
};
|
||||
let (server_transport, client_transport) = tokio::io::duplex(16 * 1024);
|
||||
let server = DbxMcpServer::with_runtime_options(Arc::new(backend), 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 resources = client.peer().list_resources(None).await.expect("list resources");
|
||||
assert_eq!(resources.resources.len(), 1);
|
||||
let templates = client.peer().list_resource_templates(None).await.expect("list resource templates");
|
||||
assert!(templates.resource_templates.is_empty());
|
||||
|
||||
let denied = client
|
||||
.peer()
|
||||
.read_resource(ReadResourceRequestParams::new("dbx://connections/local/databases"))
|
||||
.await
|
||||
.expect_err("database resource should respect the tool allowlist");
|
||||
assert!(denied.to_string().contains("TOOL_OUT_OF_SCOPE"), "{denied}");
|
||||
|
||||
client.cancel().await.expect("close MCP client");
|
||||
server_task.abort();
|
||||
}
|
||||
|
||||
#[cfg(feature = "mq-admin")]
|
||||
#[tokio::test]
|
||||
async fn kafka_peek_round_trips_over_mcp_in_local_and_web_modes() {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
#[test]
|
||||
fn version_exits_without_initializing_storage_or_transport() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let output = std::process::Command::new(env!("CARGO_BIN_EXE_dbx-mcp"))
|
||||
.arg("--version")
|
||||
.env("DBX_DATA_DIR", directory.path().join("must-not-exist"))
|
||||
.env("DBX_WEB_URL", "not a URL")
|
||||
.env("DBX_MCP_TRANSPORT", "invalid")
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(output.status.success());
|
||||
assert_eq!(String::from_utf8(output.stdout).unwrap(), format!("dbx-mcp {}\n", env!("CARGO_PKG_VERSION")));
|
||||
assert!(output.stderr.is_empty());
|
||||
assert!(!directory.path().join("must-not-exist").exists());
|
||||
}
|
||||
@@ -14,7 +14,36 @@ AI 助手 → DBX MCP → 你的数据库 → 返回结果
|
||||
↘ DBX 桌面端(打开或展示结果)
|
||||
```
|
||||
|
||||
## 快速开始
|
||||
## 原生安装(推荐)
|
||||
|
||||
无需 Node.js。macOS 或 Linux 执行:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://dbxio.com/install-mcp | sh
|
||||
```
|
||||
|
||||
Windows 在 PowerShell 中执行:
|
||||
|
||||
```powershell
|
||||
irm https://dbxio.com/install-mcp.ps1 | iex
|
||||
```
|
||||
|
||||
脚本校验下载内容,将 `dbx-mcp` 安装到 `~/.dbx/bin`,并打印 Claude Code、Cursor、Codex、ZCode 和通用 MCP 配置。请使用输出中**展开后的可执行文件绝对路径**,不要填写 `~` 或裸命令 `dbx-mcp`:GUI 客户端不一定继承 shell 的 PATH。已有的 `DBX_DATA_DIR` 等环境配置继续有效。DBX 桌面设置优先使用发现的原生二进制,找不到时保留原有 npm 启动方式。
|
||||
|
||||
重跑同一安装命令即可升级;版本相同时提示 `already up to date`。版本优先读取 `dbx-mcp --version`,旧版二进制不支持时回退读取 `~/.dbx/bin/.dbx-mcp-version`。断网时安装失败但不会替换现有二进制。下载源依次为 npmjs、npmmirror、GitHub Releases;内容校验不匹配时立即拒绝安装。
|
||||
|
||||
macOS 和 Linux 也可以使用 Homebrew:
|
||||
|
||||
```bash
|
||||
brew install t8y2/tap/dbx-mcp
|
||||
brew upgrade t8y2/tap/dbx-mcp
|
||||
```
|
||||
|
||||
Homebrew 安装后,客户端配置使用 `echo "$(brew --prefix)/bin/dbx-mcp"` 输出的绝对路径。此通道只安装 MCP 服务器,不安装独立的 `dbx` CLI。
|
||||
|
||||
npm/npx 通道保持兼容。DBX 桌面端现在默认安装独立原生服务,并为已有 npm 用户显示推荐的原生迁移入口。迁移会保留 npm 包作为兜底,也不会自动改写外部客户端文件:请将启动命令替换为页面生成的原生绝对路径,删除 Node/npx 参数并保留 `env`。仍可选择执行 `npm rm -g @dbx-app/mcp-server` 清理旧包。桌面端可以更新和卸载当前的独立原生或 Homebrew 安装;卸载独立原生版本后,保留的 npm 安装会重新成为兜底。
|
||||
|
||||
## npm 快速开始
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
@@ -123,6 +152,19 @@ DBX MCP 当前提供以下工具(具体可用工具取决于构建功能及权
|
||||
|
||||
启用连接作用域后,修改连接和桌面 UI 工具会被隐藏。
|
||||
|
||||
## Resources
|
||||
|
||||
支持 MCP Resources 的客户端可以读取连接目录,并通过模板获取数据库元数据:
|
||||
|
||||
| Resource URI | 说明 |
|
||||
| --- | --- |
|
||||
| `dbx://connections` | 当前 MCP 范围内可见的连接 |
|
||||
| `dbx://connections/{connection_id}/databases` | 指定连接中可见的数据库 |
|
||||
| `dbx://connections/{connection_id}/tables{?database,schema}` | 可选数据库或 Schema 中的表和视图 |
|
||||
| `dbx://connections/{connection_id}/table-schema{?database,schema,table}` | 指定表的字段定义;`table` 为必填参数 |
|
||||
|
||||
Resource 的发现和读取会复用对应 Tool 的白名单,以及连接、分组、数据库和运行时范围限制。查询参数值必须进行 URI 编码。SQL 执行和所有可写操作仍只通过 Tool 提供。
|
||||
|
||||
## 读取 Kafka 消息
|
||||
|
||||
包含 `mq-admin` 的构建在本地和 Web 模式下均提供 `dbx_peek_messages`。通过 `connection_id` 或 `connection_name` 选择已保存的 Kafka 连接,并指定 `topic`。工具遵守 MCP 连接范围及工具白名单,允许读取只读连接和生产连接,不提交消费位点。
|
||||
|
||||
@@ -14,7 +14,36 @@ AI agent → DBX MCP → your database → results
|
||||
↘ DBX desktop app (open or display results)
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
## Native installation (recommended)
|
||||
|
||||
No Node.js is required. On macOS or Linux:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://dbxio.com/install-mcp | sh
|
||||
```
|
||||
|
||||
On Windows, run in PowerShell:
|
||||
|
||||
```powershell
|
||||
irm https://dbxio.com/install-mcp.ps1 | iex
|
||||
```
|
||||
|
||||
The installer verifies the download, installs `dbx-mcp` into `~/.dbx/bin`, and prints ready-to-paste Claude Code, Cursor, Codex, ZCode, and generic MCP configurations. Use the **expanded absolute executable path** it prints, not `~` or the bare `dbx-mcp` command: GUI clients do not necessarily inherit your shell's PATH. Existing environment settings such as `DBX_DATA_DIR` remain applicable. DBX desktop settings prefer a discovered native binary, with the existing npm launcher as fallback.
|
||||
|
||||
Rerun the same install command to upgrade. A matching version reports `already up to date`. Version probing uses `dbx-mcp --version`, falling back to `~/.dbx/bin/.dbx-mcp-version` for older binaries. Offline installation fails without replacing an existing binary. Download sources are tried in order: npmjs, npmmirror, then GitHub Releases; an integrity mismatch stops installation.
|
||||
|
||||
Homebrew is also available on macOS and Linux:
|
||||
|
||||
```bash
|
||||
brew install t8y2/tap/dbx-mcp
|
||||
brew upgrade t8y2/tap/dbx-mcp
|
||||
```
|
||||
|
||||
For Homebrew client configurations, use the absolute path printed by `echo "$(brew --prefix)/bin/dbx-mcp"`. This installs the MCP server, not the separate `dbx` CLI.
|
||||
|
||||
The npm/npx channel remains supported unchanged. DBX desktop now installs the standalone native server by default and shows existing npm users a recommended native migration action. Migration keeps the npm package as a fallback and does not rewrite external client files automatically: replace their launch command with the generated native absolute path, remove Node/npx arguments, and preserve `env`. Removing the old global package with `npm rm -g @dbx-app/mcp-server` remains optional. The desktop can update and uninstall the active standalone or Homebrew native installation; uninstalling a standalone native installation reveals any retained npm installation again.
|
||||
|
||||
## Quick Start with npm
|
||||
|
||||
<Steps>
|
||||
<Step>
|
||||
@@ -123,6 +152,19 @@ DBX MCP provides the following tools (availability depends on build features and
|
||||
|
||||
Connection-scoped sessions hide connection-mutating and desktop UI tools.
|
||||
|
||||
## Resources
|
||||
|
||||
Clients that support MCP Resources can read the connection catalog and expand templates for database metadata:
|
||||
|
||||
| Resource URI | Description |
|
||||
| --- | --- |
|
||||
| `dbx://connections` | Connections visible to the current MCP scope |
|
||||
| `dbx://connections/{connection_id}/databases` | Databases visible through one connection |
|
||||
| `dbx://connections/{connection_id}/tables{?database,schema}` | Tables and views in an optional database/schema |
|
||||
| `dbx://connections/{connection_id}/table-schema{?database,schema,table}` | Column definitions for a table; `table` is required |
|
||||
|
||||
Resource discovery and reads reuse the corresponding Tool allowlist plus connection, group, database, and runtime scopes. Query parameter values must be URI encoded. SQL execution and all write-capable operations remain Tools.
|
||||
|
||||
## Read Kafka Messages
|
||||
|
||||
`dbx_peek_messages` is available in builds with `mq-admin`, in both local and Web modes. Select a saved Kafka connection by `connection_id` or `connection_name` and provide `topic`. It respects MCP connection scopes and tool allowlists and works with read-only and production connections without committing consumer offsets.
|
||||
|
||||
+27
-1
@@ -1,7 +1,33 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { test } from "vitest";
|
||||
import { issueRedirectPath, pluginDetailShellRequest, sanitizeReturnTo, signPayload, staticAssetCacheControl, verifySignedPayload } from "../worker";
|
||||
import worker, { issueRedirectPath, pluginDetailShellRequest, sanitizeReturnTo, signPayload, staticAssetCacheControl, verifySignedPayload } from "../worker";
|
||||
|
||||
test("native installers bypass HTML routing and are served as plain text", async () => {
|
||||
const wrangler = JSON.parse(readFileSync(new URL("../wrangler.json", import.meta.url), "utf8"));
|
||||
for (const pathname of ["/install-mcp", "/install-mcp.ps1"]) {
|
||||
assert.ok(wrangler.assets.run_worker_first.includes(pathname));
|
||||
const source = readFileSync(new URL(`../public/${pathname === "/install-mcp" ? "install-mcp.sh" : "install-mcp.ps1"}`, import.meta.url), "utf8");
|
||||
const env = { ASSETS: { fetch: async (request: Request) => {
|
||||
assert.equal(new URL(request.url).pathname, pathname === "/install-mcp" ? "/install-mcp.sh" : pathname);
|
||||
return new Response(source);
|
||||
} } };
|
||||
const response = await worker.fetch(new Request(`https://dbxio.com${pathname}`), env);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("Content-Type"), "text/plain; charset=utf-8");
|
||||
assert.equal(await response.text(), source);
|
||||
assert.equal(await (await worker.fetch(new Request(`https://dbxio.com${pathname}`, { method: "HEAD" }), env)).text(), "");
|
||||
assert.equal((await worker.fetch(new Request(`https://dbxio.com${pathname}`, { method: "POST" }), env)).status, 405);
|
||||
for (const status of [200, 404]) {
|
||||
const missing = await worker.fetch(new Request(`https://dbxio.com${pathname}`), {
|
||||
ASSETS: { fetch: async () => new Response("<html>not found</html>", { status, headers: { "Content-Type": "text/html" } }) },
|
||||
});
|
||||
assert.equal(missing.status, 503);
|
||||
assert.equal(missing.headers.get("Cache-Control"), "no-store");
|
||||
assert.doesNotMatch(await missing.text(), /<html>/);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("plugin detail shell routes are routed to the worker before static 404 handling", () => {
|
||||
const wrangler = JSON.parse(readFileSync(new URL("../wrangler.json", import.meta.url), "utf8"));
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
[Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
|
||||
|
||||
function Get-Download([string]$Url, [string]$Destination) {
|
||||
try {
|
||||
Invoke-WebRequest -UseBasicParsing -Uri $Url -OutFile $Destination -TimeoutSec 120 | Out-Null
|
||||
return $true
|
||||
} catch { return $false }
|
||||
}
|
||||
function Test-Version([string]$Value) { return $Value -cmatch '^[0-9]+\.[0-9]+\.[0-9]+$' }
|
||||
function Read-Json([string]$Path) {
|
||||
try { return Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json } catch { return $null }
|
||||
}
|
||||
function Get-InstalledVersion([string]$Binary, [string]$Marker) {
|
||||
if (!(Test-Path -LiteralPath $Binary -PathType Leaf)) { return '' }
|
||||
$process = New-Object System.Diagnostics.Process
|
||||
try {
|
||||
$process.StartInfo = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$process.StartInfo.FileName = $Binary
|
||||
$process.StartInfo.Arguments = '--version'
|
||||
$process.StartInfo.UseShellExecute = $false
|
||||
$process.StartInfo.CreateNoWindow = $true
|
||||
$process.StartInfo.RedirectStandardInput = $true
|
||||
$process.StartInfo.RedirectStandardOutput = $true
|
||||
$process.StartInfo.RedirectStandardError = $true
|
||||
[void]$process.Start()
|
||||
$process.StandardInput.Close()
|
||||
if (!$process.WaitForExit(3000)) { $process.Kill(); $process.WaitForExit() }
|
||||
if ($process.ExitCode -eq 0) {
|
||||
$reported = $process.StandardOutput.ReadToEnd().Trim() -replace '^dbx-mcp ', ''
|
||||
if (Test-Version $reported) { return $reported }
|
||||
}
|
||||
} catch {} finally { $process.Dispose() }
|
||||
if (Test-Path -LiteralPath $Marker -PathType Leaf) {
|
||||
$reported = (Get-Content -LiteralPath $Marker -Raw).Trim()
|
||||
if (Test-Version $reported) { return $reported }
|
||||
}
|
||||
return ''
|
||||
}
|
||||
function Move-Atomic([string]$Source, [string]$Destination) {
|
||||
if ([IO.File]::Exists($Destination)) { [IO.File]::Replace($Source, $Destination, [NullString]::Value) }
|
||||
else { [IO.File]::Move($Source, $Destination) }
|
||||
}
|
||||
function Set-InstallPath([string]$Directory) {
|
||||
try {
|
||||
$userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
|
||||
if (($userPath -split ';' | ForEach-Object { $_.TrimEnd('\') }) -notcontains $Directory.TrimEnd('\')) {
|
||||
[Environment]::SetEnvironmentVariable('Path', "$Directory;$userPath", 'User')
|
||||
}
|
||||
if (($env:Path -split ';') -notcontains $Directory) { $env:Path = "$Directory;$env:Path" }
|
||||
} catch { Write-Warning "Could not update user PATH. Client configs use the absolute path: $Directory" }
|
||||
}
|
||||
function Show-Configs([string]$Binary) {
|
||||
Write-Output "`nClaude Code (.mcp.json), Cursor (.cursor/mcp.json), ZCode config, generic JSON:"
|
||||
@{ mcpServers = @{ dbx = @{ command = $Binary } } } | ConvertTo-Json -Depth 5
|
||||
$quoted = ConvertTo-Json -InputObject $Binary -Compress
|
||||
Write-Output "`nCodex (~/.codex/config.toml):`n[mcp_servers.dbx]`ncommand = $quoted"
|
||||
$launcher = Get-Command dbx-mcp-server -ErrorAction SilentlyContinue
|
||||
if ($launcher) {
|
||||
Write-Output "`nExisting npm launcher: $($launcher.Source)"
|
||||
Write-Output "Replace its command with $quoted and remove Node/npx arguments; preserve env."
|
||||
Write-Output 'Optional cleanup: npm rm -g @dbx-app/mcp-server'
|
||||
}
|
||||
}
|
||||
|
||||
function Get-McpPlatform {
|
||||
if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT) { throw 'Use install-mcp on macOS or Linux.' }
|
||||
try { $architecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString() }
|
||||
catch { $architecture = $env:PROCESSOR_ARCHITEW6432; if (!$architecture) { $architecture = $env:PROCESSOR_ARCHITECTURE } }
|
||||
switch ($architecture) {
|
||||
{ $_ -in 'Arm64', 'ARM64' } { return 'win32-arm64' }
|
||||
{ $_ -in 'X64', 'AMD64' } { return 'win32-x64' }
|
||||
default { throw 'Unsupported CPU architecture; Windows x64 or ARM64 is required.' }
|
||||
}
|
||||
}
|
||||
|
||||
function Install-DbxMcp {
|
||||
$platform = Get-McpPlatform
|
||||
$installDir = [IO.Path]::GetFullPath((Join-Path $HOME '.dbx\bin'))
|
||||
[IO.Directory]::CreateDirectory($installDir) | Out-Null
|
||||
$binary = Join-Path $installDir 'dbx-mcp.exe'
|
||||
$marker = Join-Path $installDir '.dbx-mcp-version'
|
||||
$workDir = Join-Path $installDir ('.dbx-mcp-install.' + [Guid]::NewGuid().ToString('N'))
|
||||
[IO.Directory]::CreateDirectory($workDir) | Out-Null
|
||||
try {
|
||||
$metadataFile = Join-Path $workDir 'metadata.json'
|
||||
$registries = @('https://registry.npmjs.org', 'https://registry.npmmirror.com')
|
||||
$version = ''
|
||||
foreach ($registry in $registries) {
|
||||
if (Get-Download "$registry/@dbx-app/mcp-server/latest" $metadataFile) {
|
||||
$candidate = (Read-Json $metadataFile).version
|
||||
if (Test-Version $candidate) { $version = $candidate; break }
|
||||
}
|
||||
}
|
||||
if (!$version -and (Get-Download 'https://api.github.com/repos/t8y2/dbx/git/matching-refs/tags/packages-v' $metadataFile)) {
|
||||
$versions = @(Read-Json $metadataFile) | ForEach-Object {
|
||||
if ($_.ref -cmatch '^refs/tags/packages-v([0-9]+\.[0-9]+\.[0-9]+)$') { [version]$Matches[1] }
|
||||
} | Sort-Object -Descending
|
||||
if ($versions) { $version = [string]@($versions)[0] }
|
||||
}
|
||||
if (!(Test-Version $version)) { throw 'Unable to resolve a release (offline or registries unavailable); existing installation unchanged.' }
|
||||
$current = Get-InstalledVersion $binary $marker
|
||||
if ($current -eq $version) {
|
||||
Write-Output "dbx-mcp $version already up to date"
|
||||
Set-InstallPath $installDir
|
||||
Show-Configs $binary
|
||||
return
|
||||
}
|
||||
if ((Test-Version $current) -and [version]$current -gt [version]$version) {
|
||||
Write-Output "dbx-mcp $current is newer than available $version; keeping current installation"
|
||||
Set-InstallPath $installDir
|
||||
Show-Configs $binary
|
||||
return
|
||||
}
|
||||
$archive = Join-Path $workDir 'archive.tgz'
|
||||
$stagedBinary = Join-Path $workDir 'dbx-mcp.exe'
|
||||
$downloaded = $false
|
||||
foreach ($registry in $registries) {
|
||||
if (!(Get-Command tar.exe -ErrorAction SilentlyContinue)) { break }
|
||||
if (!(Get-Download "$registry/@dbx-app/mcp-$platform/$version" $metadataFile)) { continue }
|
||||
$integrity = (Read-Json $metadataFile).dist.integrity
|
||||
if ($integrity -cnotmatch '^sha512-[A-Za-z0-9+/]{86}==$') { continue }
|
||||
if (!(Get-Download "$registry/@dbx-app/mcp-$platform/-/mcp-$platform-$version.tgz" $archive)) { continue }
|
||||
$stream = [IO.File]::OpenRead($archive)
|
||||
$hasher = [Security.Cryptography.SHA512]::Create()
|
||||
try { $actual = 'sha512-' + [Convert]::ToBase64String($hasher.ComputeHash($stream)) }
|
||||
finally { $stream.Dispose(); $hasher.Dispose() }
|
||||
if ($actual -cne $integrity) { throw "Integrity verification failed for $registry; refusing to install." }
|
||||
& tar.exe -xzf $archive -C $workDir 'package/bin/dbx-mcp.exe' 2>$null
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Binary missing from npm archive.' }
|
||||
[IO.File]::Move((Join-Path $workDir 'package\bin\dbx-mcp.exe'), $stagedBinary)
|
||||
$downloaded = $true
|
||||
break
|
||||
}
|
||||
if (!$downloaded) {
|
||||
$asset = "dbx-mcp-$platform.zip"
|
||||
$release = "https://github.com/t8y2/dbx/releases/download/packages-v$version"
|
||||
$checksums = Join-Path $workDir 'SHA256SUMS'
|
||||
if (!(Get-Download "$release/SHA256SUMS" $checksums) -or !(Get-Download "$release/$asset" $archive)) {
|
||||
throw 'All download sources unavailable; existing installation unchanged.'
|
||||
}
|
||||
$hashes = @(Get-Content -LiteralPath $checksums | ForEach-Object {
|
||||
if ($_ -match ('^([a-fA-F0-9]{64})\s+\*?' + [regex]::Escape($asset) + '$')) { $Matches[1] }
|
||||
})
|
||||
if ($hashes.Count -ne 1 -or (Get-FileHash -LiteralPath $archive -Algorithm SHA256).Hash -ne $hashes[0]) {
|
||||
throw 'SHA256 verification failed; refusing to install.'
|
||||
}
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$zip = [IO.Compression.ZipFile]::OpenRead($archive)
|
||||
try {
|
||||
$entry = $zip.GetEntry('dbx-mcp.exe')
|
||||
if (!$entry) { throw 'Binary missing from Release archive.' }
|
||||
[IO.Compression.ZipFileExtensions]::ExtractToFile($entry, $stagedBinary)
|
||||
} finally { $zip.Dispose() }
|
||||
}
|
||||
$staged = Get-Item -LiteralPath $stagedBinary
|
||||
if (!$staged.Length -or ($staged.Attributes -band [IO.FileAttributes]::ReparsePoint)) { throw 'Downloaded binary is empty or a link.' }
|
||||
$stagedMarker = Join-Path $workDir 'version'
|
||||
[IO.File]::WriteAllText($stagedMarker, "$version`n", (New-Object Text.UTF8Encoding($false)))
|
||||
Move-Atomic $stagedBinary $binary
|
||||
Move-Atomic $stagedMarker $marker
|
||||
Set-InstallPath $installDir
|
||||
Write-Output "Installed dbx-mcp $version at $binary"
|
||||
Show-Configs $binary
|
||||
} finally { Remove-Item -LiteralPath $workDir -Recurse -Force -ErrorAction SilentlyContinue }
|
||||
}
|
||||
|
||||
Install-DbxMcp
|
||||
@@ -0,0 +1,152 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
fail() { printf 'dbx-mcp: %s\n' "$*" >&2; exit 1; }
|
||||
fetch() { curl -qfsSL --connect-timeout 10 --max-time 120 --proto '=https' --proto-redir '=https' "$1" -o "$2" 2>/dev/null; }
|
||||
valid_version() {
|
||||
case "$1" in ''|*[!0-9.]*) return 1 ;; esac
|
||||
printf '%s\n' "$1" | LC_ALL=C grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$'
|
||||
}
|
||||
newer_version() {
|
||||
awk -v current="$1" -v latest="$2" 'BEGIN {
|
||||
split(current, currentParts, "."); split(latest, latestParts, ".")
|
||||
for (part = 1; part <= 3; part++) {
|
||||
if (currentParts[part] + 0 > latestParts[part] + 0) exit 0
|
||||
if (currentParts[part] + 0 < latestParts[part] + 0) exit 1
|
||||
}
|
||||
exit 1
|
||||
}'
|
||||
}
|
||||
json_field() {
|
||||
LC_ALL=C awk -v wanted="$1" '
|
||||
{ text = text $0 }
|
||||
END {
|
||||
while (match(text, /"([^"\\]|\\.)*"|[{}:,]/)) {
|
||||
token = substr(text, RSTART, RLENGTH); text = substr(text, RSTART + RLENGTH)
|
||||
if (token == "{") { depth++; key[depth] = "" }
|
||||
else if (token == "}") { delete key[depth]; depth-- }
|
||||
else if (substr(token, 1, 1) == "\"") {
|
||||
value = substr(token, 2, length(token) - 2)
|
||||
if (previous == "{" || previous == ",") key[depth] = value
|
||||
else if (previous == ":" && ((depth == 1 && key[depth] == wanted) ||
|
||||
(depth == 2 && key[1] == "dist" && wanted == "integrity" && key[depth] == wanted))) print value
|
||||
}
|
||||
previous = token
|
||||
}
|
||||
}'
|
||||
}
|
||||
json_quote() {
|
||||
LC_ALL=C awk 'BEGIN { printf "\"" } {
|
||||
if (NR > 1) printf "\\n"
|
||||
for (position = 1; position <= length($0); position++) {
|
||||
character = substr($0, position, 1)
|
||||
if (character == "\\" || character == "\"") printf "\\%s", character
|
||||
else if (character == "\t") printf "\\t"
|
||||
else if (character == "\r") printf "\\r"
|
||||
else printf "%s", character
|
||||
}
|
||||
} END { printf "\"" }'
|
||||
}
|
||||
print_configs() {
|
||||
quoted_binary=$(printf '%s' "$binary" | json_quote)
|
||||
printf '\nClaude Code (.mcp.json), Cursor (.cursor/mcp.json), ZCode config, generic JSON:\n'
|
||||
printf '{"mcpServers":{"dbx":{"command":%s}}}\n' "$quoted_binary"
|
||||
printf '\nCodex (~/.codex/config.toml):\n[mcp_servers.dbx]\ncommand = %s\n' "$quoted_binary"
|
||||
if command -v dbx-mcp-server >/dev/null 2>&1; then
|
||||
printf '\nExisting npm launcher: %s\n' "$(command -v dbx-mcp-server)"
|
||||
printf 'Replace its command with %s and remove Node/npx arguments; preserve env.\n' "$quoted_binary"
|
||||
printf 'Optional cleanup: npm rm -g @dbx-app/mcp-server\n'
|
||||
fi
|
||||
}
|
||||
configure_path() {
|
||||
case ":${PATH:-}:" in *":$install_dir:"*) return ;; esac
|
||||
case "${SHELL:-}" in
|
||||
*/zsh) rc_file="${ZDOTDIR:-$HOME}/.zshrc" ;;
|
||||
*/bash)
|
||||
if [ "$operating_system" = Darwin ]; then rc_file="$HOME/.bash_profile"; else rc_file="$HOME/.bashrc"; fi
|
||||
;;
|
||||
*) printf '\nAdd %s to PATH for terminal use. Client configs already use the absolute path.\n' "$install_dir"; return ;;
|
||||
esac
|
||||
if ! grep -Fq '# added by dbx installer' "$rc_file" 2>/dev/null; then
|
||||
printf '\n# added by dbx installer\nexport PATH="$HOME/.dbx/bin:$PATH"\n' >> "$rc_file" ||
|
||||
printf 'Could not update %s; add %s to PATH manually.\n' "$rc_file" "$install_dir" >&2
|
||||
fi
|
||||
}
|
||||
|
||||
for dependency in curl tar openssl awk sed grep sort; do
|
||||
command -v "$dependency" >/dev/null 2>&1 || fail "Required command not found: $dependency"
|
||||
done
|
||||
operating_system=$(uname -s)
|
||||
case "$operating_system" in Darwin) platform=darwin ;; Linux) platform=linux ;; *) fail 'Use install-mcp.ps1 on Windows.' ;; esac
|
||||
case "$(uname -m)" in arm64|aarch64) architecture=arm64 ;; x86_64|amd64) architecture=x64 ;; *) fail 'Unsupported CPU architecture.' ;; esac
|
||||
platform="$platform-$architecture"
|
||||
case "$platform" in linux-*) platform="$platform-gnu" ;; esac
|
||||
: "${HOME:?HOME must be set}"
|
||||
mkdir -p "$HOME/.dbx/bin"
|
||||
install_dir=$(cd "$HOME/.dbx/bin" && pwd -P)
|
||||
binary="$install_dir/dbx-mcp"
|
||||
marker="$install_dir/.dbx-mcp-version"
|
||||
work_dir=$(mktemp -d "$install_dir/.dbx-mcp-install.XXXXXX")
|
||||
trap 'rm -rf "$work_dir"' EXIT
|
||||
trap 'exit 1' HUP INT TERM
|
||||
umask 077
|
||||
|
||||
version=''
|
||||
for registry in https://registry.npmjs.org https://registry.npmmirror.com; do
|
||||
if fetch "$registry/@dbx-app/mcp-server/latest" "$work_dir/latest.json"; then
|
||||
candidate=$(json_field version < "$work_dir/latest.json")
|
||||
if valid_version "$candidate"; then version=$candidate; break; fi
|
||||
fi
|
||||
done
|
||||
if [ -z "$version" ] && fetch 'https://api.github.com/repos/t8y2/dbx/git/matching-refs/tags/packages-v' "$work_dir/refs.json"; then
|
||||
version=$(json_field ref < "$work_dir/refs.json" | sed -n 's|^refs/tags/packages-v\([0-9][0-9]*\.[0-9][0-9]*\.[0-9][0-9]*\)$|\1|p' | sort -t . -k1,1n -k2,2n -k3,3n | tail -n 1)
|
||||
fi
|
||||
valid_version "$version" || fail 'Unable to resolve a release (offline or registries unavailable); existing installation unchanged.'
|
||||
current=''
|
||||
if [ -x "$binary" ]; then
|
||||
current=$("$binary" --version </dev/null 2>/dev/null) || current=''
|
||||
current=${current#dbx-mcp }
|
||||
if ! valid_version "$current" && [ -f "$marker" ]; then current=$(cat "$marker"); fi
|
||||
fi
|
||||
if [ "$current" = "$version" ]; then
|
||||
printf 'dbx-mcp %s already up to date\n' "$version"
|
||||
configure_path
|
||||
print_configs
|
||||
exit 0
|
||||
fi
|
||||
if valid_version "$current" && newer_version "$current" "$version"; then
|
||||
printf 'dbx-mcp %s is newer than available %s; keeping current installation\n' "$current" "$version"
|
||||
configure_path
|
||||
print_configs
|
||||
exit 0
|
||||
fi
|
||||
|
||||
downloaded=false
|
||||
for registry in https://registry.npmjs.org https://registry.npmmirror.com; do
|
||||
if ! fetch "$registry/@dbx-app/mcp-$platform/$version" "$work_dir/package.json"; then continue; fi
|
||||
integrity=$(json_field integrity < "$work_dir/package.json")
|
||||
if ! printf '%s\n' "$integrity" | grep -Eq '^sha512-[A-Za-z0-9+/]{86}==$'; then continue; fi
|
||||
if ! fetch "$registry/@dbx-app/mcp-$platform/-/mcp-$platform-$version.tgz" "$work_dir/archive"; then continue; fi
|
||||
actual="sha512-$(openssl dgst -sha512 -binary "$work_dir/archive" | openssl base64 -A)"
|
||||
[ "$actual" = "$integrity" ] || fail "Integrity verification failed for $registry; refusing to install."
|
||||
tar -xOzf "$work_dir/archive" package/bin/dbx-mcp > "$work_dir/dbx-mcp" || fail 'Binary missing from npm archive.'
|
||||
downloaded=true
|
||||
break
|
||||
done
|
||||
if [ "$downloaded" = false ]; then
|
||||
asset="dbx-mcp-$platform.tar.gz"
|
||||
release="https://github.com/t8y2/dbx/releases/download/packages-v$version"
|
||||
fetch "$release/SHA256SUMS" "$work_dir/SHA256SUMS" && fetch "$release/$asset" "$work_dir/archive" || fail 'All download sources unavailable; existing installation unchanged.'
|
||||
expected=$(awk -v asset="$asset" '$2 == asset { print $1 }' "$work_dir/SHA256SUMS")
|
||||
actual=$(openssl dgst -sha256 "$work_dir/archive" | sed 's/^.*= //')
|
||||
printf '%s\n' "$expected" | grep -Eq '^[a-fA-F0-9]{64}$' && [ "$actual" = "$expected" ] || fail 'SHA256 verification failed; refusing to install.'
|
||||
tar -xOzf "$work_dir/archive" dbx-mcp > "$work_dir/dbx-mcp" || fail 'Binary missing from Release archive.'
|
||||
fi
|
||||
[ -s "$work_dir/dbx-mcp" ] || fail 'Downloaded binary is empty.'
|
||||
chmod 755 "$work_dir/dbx-mcp"
|
||||
printf '%s\n' "$version" > "$work_dir/version"
|
||||
mv -f "$work_dir/dbx-mcp" "$binary"
|
||||
mv -f "$work_dir/version" "$marker"
|
||||
configure_path
|
||||
printf 'Installed dbx-mcp %s at %s\n' "$version" "$binary"
|
||||
print_configs
|
||||
@@ -633,6 +633,19 @@ export class IssueSubmissionLimiter {
|
||||
export default {
|
||||
async fetch(request: Request, env: Env): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/install-mcp" || url.pathname === "/install-mcp.ps1") {
|
||||
if (request.method !== "GET" && request.method !== "HEAD") {
|
||||
return new Response("Method not allowed\n", { status: 405, headers: { Allow: "GET, HEAD", "Content-Type": "text/plain; charset=utf-8" } });
|
||||
}
|
||||
const assetPath = url.pathname === "/install-mcp" ? "/install-mcp.sh" : "/install-mcp.ps1";
|
||||
const asset = await env.ASSETS.fetch(new Request(`${url.origin}${assetPath}`));
|
||||
if (!asset.ok || asset.headers.get("Content-Type")?.includes("text/html")) {
|
||||
return new Response("Installer unavailable\n", { status: 503, headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store" } });
|
||||
}
|
||||
return new Response(request.method === "HEAD" ? null : asset.body, {
|
||||
headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "public, max-age=300", "X-Content-Type-Options": "nosniff" },
|
||||
});
|
||||
}
|
||||
const issueRedirect = issueRedirectPath(url.pathname, preferredIssueLanguage(request));
|
||||
if (issueRedirect && request.method === "GET") return Response.redirect(`${url.origin}${issueRedirect}`, 308);
|
||||
// Turkish docs routes no longer exist; keep old /tr/* links working by sending them to English.
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
"binding": "ASSETS",
|
||||
"not_found_handling": "404-page",
|
||||
"run_worker_first": [
|
||||
"/install-mcp",
|
||||
"/install-mcp.ps1",
|
||||
"/api/*",
|
||||
"/cn/plugins/*",
|
||||
"/en/plugins/*",
|
||||
|
||||
@@ -175,6 +175,19 @@ When connection scoping is enabled, mutating connection tools and desktop UI too
|
||||
|
||||
Salesforce connections take SOQL through `dbx_execute_query` and list objects through `dbx_list_tables`. `dbx_execute_batch` and `dbx_open_session` are refused: SOQL is read-only, and every call is a stateless REST request with no session to pin. A record write is a two-step confirmed operation — `dbx_salesforce_prepare_write` returns a summary plus a single-use `confirm_token` that expires after 5 minutes, a person approves that summary, and `dbx_salesforce_apply_write` sends exactly the prepared statement. Preparing needs the per-connection **Allow DML** switch in DBX Settings → MCP, which is off by default, and Salesforce cannot roll an applied write back.
|
||||
|
||||
## Resources
|
||||
|
||||
Clients that support MCP Resources can read the connection catalog and expand templates for database metadata:
|
||||
|
||||
| Resource URI | Description |
|
||||
| --- | --- |
|
||||
| `dbx://connections` | Connections visible to the current MCP scope |
|
||||
| `dbx://connections/{connection_id}/databases` | Databases visible through one connection |
|
||||
| `dbx://connections/{connection_id}/tables{?database,schema}` | Tables and views in an optional database/schema |
|
||||
| `dbx://connections/{connection_id}/table-schema{?database,schema,table}` | Column definitions for a table; `table` is required |
|
||||
|
||||
Resource discovery and reads reuse the corresponding Tool allowlist plus connection, group, database, and runtime scopes. Query parameter values must be URI encoded. SQL execution and all write-capable operations remain Tools.
|
||||
|
||||
## Execution Modes
|
||||
|
||||
### Local native mode
|
||||
@@ -558,6 +571,19 @@ MCP 配置:
|
||||
|
||||
Salesforce 连接通过 `dbx_execute_query` 执行 SOQL,通过 `dbx_list_tables` 列出对象。`dbx_execute_batch` 与 `dbx_open_session` 会被拒绝:SOQL 只读,且每次调用都是无状态 REST 请求,没有可固定的会话。写入记录是两步确认操作——`dbx_salesforce_prepare_write` 返回摘要和一次性 `confirm_token`(5 分钟后过期),由人确认该摘要后,`dbx_salesforce_apply_write` 才会发送那条已准备好的语句。准备写入需要在 DBX 设置 → MCP 中为该连接开启 **允许 DML**(默认关闭),且 Salesforce 无法回滚已应用的写入。
|
||||
|
||||
### Resources
|
||||
|
||||
支持 MCP Resources 的客户端可以读取连接目录,并通过模板获取数据库元数据:
|
||||
|
||||
| Resource URI | 说明 |
|
||||
| --- | --- |
|
||||
| `dbx://connections` | 当前 MCP 范围内可见的连接 |
|
||||
| `dbx://connections/{connection_id}/databases` | 指定连接中可见的数据库 |
|
||||
| `dbx://connections/{connection_id}/tables{?database,schema}` | 可选数据库或 Schema 中的表和视图 |
|
||||
| `dbx://connections/{connection_id}/table-schema{?database,schema,table}` | 指定表的字段定义;`table` 为必填参数 |
|
||||
|
||||
Resource 的发现和读取会复用对应 Tool 的白名单,以及连接、分组、数据库和运行时范围限制。查询参数值必须进行 URI 编码。SQL 执行和所有可写操作仍只通过 Tool 提供。
|
||||
|
||||
### 本地数据目录
|
||||
|
||||
- macOS:`~/Library/Application Support/com.dbx.app/dbx.db`
|
||||
|
||||
+120
-72
@@ -7,13 +7,13 @@ use std::time::Duration;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::{AppHandle, Manager};
|
||||
|
||||
#[path = "mcp_native_installation.rs"]
|
||||
mod native_installation;
|
||||
|
||||
const MCP_PACKAGE_NAME: &str = "@dbx-app/mcp-server";
|
||||
const MCP_LATEST_URL: &str = "https://registry.npmjs.org/@dbx-app%2fmcp-server/latest";
|
||||
const MCP_INSTALL_COMMAND: &str = "npm install -g @dbx-app/mcp-server@latest";
|
||||
const MCP_PNPM_UPDATE_COMMAND: &str = "pnpm update -g @dbx-app/mcp-server";
|
||||
const MCP_UNINSTALL_COMMAND: &str = "npm uninstall -g @dbx-app/mcp-server";
|
||||
const MCP_PNPM_UNINSTALL_COMMAND: &str = "pnpm remove -g @dbx-app/mcp-server";
|
||||
const MCP_BUN_UPDATE_COMMAND: &str = "bun add -g @dbx-app/mcp-server@latest";
|
||||
const MCP_BUN_UNINSTALL_COMMAND: &str = "bun remove -g @dbx-app/mcp-server";
|
||||
const MCP_MIN_NODE_VERSION: NodeVersion = NodeVersion { major: 18, minor: 18, patch: 0 };
|
||||
const MCP_MIN_NODE_VERSION_REQUIREMENT: &str = ">=18.18.0";
|
||||
@@ -22,7 +22,9 @@ const SHELL_COMMAND_MARKER: &str = "__DBX_MCP_COMMAND_OUTPUT_START__";
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct McpServerStatus {
|
||||
pub installed: bool,
|
||||
pub installation_source: Option<String>,
|
||||
pub npm_available: bool,
|
||||
pub npm_installed: bool,
|
||||
pub node_path: Option<String>,
|
||||
pub node_version: Option<String>,
|
||||
pub current_version: Option<String>,
|
||||
@@ -163,14 +165,6 @@ impl NodeRuntime {
|
||||
)
|
||||
}
|
||||
|
||||
fn update_command(&self) -> &'static str {
|
||||
match self.mcp_installation.as_ref().map(|installation| &installation.package_manager) {
|
||||
Some(McpPackageManager::Pnpm { .. } | McpPackageManager::PnpmUnavailable { .. }) => MCP_PNPM_UPDATE_COMMAND,
|
||||
Some(McpPackageManager::Bun { .. }) => MCP_BUN_UPDATE_COMMAND,
|
||||
_ => MCP_INSTALL_COMMAND,
|
||||
}
|
||||
}
|
||||
|
||||
fn uninstall_command(&self) -> &'static str {
|
||||
match self.mcp_installation.as_ref().map(|installation| &installation.package_manager) {
|
||||
Some(McpPackageManager::Pnpm { .. } | McpPackageManager::PnpmUnavailable { .. }) => {
|
||||
@@ -289,32 +283,60 @@ struct NodeVersion {
|
||||
pub async fn check_mcp_server_status(app: AppHandle) -> Result<McpServerStatus, String> {
|
||||
let default_data_dir = app.path().app_data_dir().map_err(|error| error.to_string())?;
|
||||
let data_dir = crate::data_dir::resolve_data_dir_with_mode(default_data_dir).custom_data_dir().map(path_string);
|
||||
let local_status = tauri::async_runtime::spawn_blocking(resolve_node_runtime);
|
||||
let local_status = tauri::async_runtime::spawn_blocking(|| {
|
||||
let native = native_installation::resolve();
|
||||
let runtime = resolve_node_runtime();
|
||||
(native, runtime)
|
||||
});
|
||||
let latest_version = fetch_latest_mcp_version();
|
||||
let (local_status, latest_version) = tokio::join!(local_status, latest_version);
|
||||
let runtime = local_status.map_err(|err| err.to_string())?;
|
||||
let (native, runtime) = local_status.map_err(|err| err.to_string())?;
|
||||
let installation = runtime.as_ref().and_then(|runtime| runtime.mcp_installation.as_ref());
|
||||
let installation_status = mcp_installation_status_fields(runtime.as_ref());
|
||||
let npm_installed = installation.is_some();
|
||||
let npm_available = runtime.is_some();
|
||||
let node_path = runtime.as_ref().map(|runtime| path_string(&runtime.node_path));
|
||||
let node_version = runtime.as_ref().map(|runtime| runtime.node_version.clone());
|
||||
if let Some(native) = native {
|
||||
let latest_version = latest_version.ok();
|
||||
let update_available = native
|
||||
.version
|
||||
.as_deref()
|
||||
.zip(latest_version.as_deref())
|
||||
.is_some_and(|(current, latest)| dbx_core::update::is_newer_version(latest, current));
|
||||
return Ok(McpServerStatus {
|
||||
installed: true,
|
||||
installation_source: Some(native.source().to_string()),
|
||||
npm_available,
|
||||
npm_installed,
|
||||
node_path,
|
||||
node_version,
|
||||
current_version: native.version.clone(),
|
||||
latest_version,
|
||||
update_available,
|
||||
bin_path: Some(path_string(&native.binary)),
|
||||
native_bin_path: Some(path_string(&native.binary)),
|
||||
script_path: None,
|
||||
data_dir,
|
||||
install_command: native.install_command().to_string(),
|
||||
update_command: native.update_command().to_string(),
|
||||
uninstall_command: native.uninstall_command(),
|
||||
error: None,
|
||||
});
|
||||
}
|
||||
let installation_status = mcp_installation_status_fields(runtime.as_ref());
|
||||
let current_version = installation_status.current_version;
|
||||
let latest_version = latest_version.ok();
|
||||
let update_available = current_version
|
||||
.as_deref()
|
||||
.zip(latest_version.as_deref())
|
||||
.is_some_and(|(current, latest)| dbx_core::update::is_newer_version(latest, current));
|
||||
let error = match runtime.as_ref() {
|
||||
None => Some(format!(
|
||||
"Unable to resolve a compatible Node.js ({}) and npm runtime.",
|
||||
MCP_MIN_NODE_VERSION_REQUIREMENT
|
||||
)),
|
||||
Some(_) => mcp_status_installation_error(installation),
|
||||
};
|
||||
let error = mcp_status_installation_error(installation);
|
||||
|
||||
Ok(McpServerStatus {
|
||||
installed: installation_status.installed,
|
||||
installation_source: installation_status.installed.then(|| "npm".to_string()),
|
||||
npm_available,
|
||||
npm_installed,
|
||||
node_path,
|
||||
node_version,
|
||||
current_version,
|
||||
@@ -324,8 +346,8 @@ pub async fn check_mcp_server_status(app: AppHandle) -> Result<McpServerStatus,
|
||||
native_bin_path: installation_status.native_bin_path,
|
||||
script_path: installation_status.script_path,
|
||||
data_dir,
|
||||
install_command: MCP_INSTALL_COMMAND.to_string(),
|
||||
update_command: runtime.as_ref().map(NodeRuntime::update_command).unwrap_or(MCP_INSTALL_COMMAND).to_string(),
|
||||
install_command: native_installation::install_command().to_string(),
|
||||
update_command: native_installation::install_command().to_string(),
|
||||
uninstall_command: runtime
|
||||
.as_ref()
|
||||
.map(NodeRuntime::uninstall_command)
|
||||
@@ -338,63 +360,91 @@ pub async fn check_mcp_server_status(app: AppHandle) -> Result<McpServerStatus,
|
||||
#[tauri::command]
|
||||
pub async fn install_mcp_server() -> Result<String, String> {
|
||||
tauri::async_runtime::spawn_blocking(|| {
|
||||
let runtime = resolve_node_runtime().ok_or_else(|| {
|
||||
format!(
|
||||
"Unable to resolve a compatible Node.js ({}) and npm runtime. Install Node.js with npm and try again.",
|
||||
MCP_MIN_NODE_VERSION_REQUIREMENT
|
||||
)
|
||||
})?;
|
||||
let output = runtime.install_or_update()?;
|
||||
|
||||
if !output.success {
|
||||
let error_msg = if !output.stderr.is_empty() { output.stderr } else { output.stdout };
|
||||
return Err(format!("Installation failed: {}", error_msg));
|
||||
let native = native_installation::resolve();
|
||||
if native.is_some() {
|
||||
let npm_installed = resolve_node_runtime().is_some_and(|runtime| runtime.has_mcp_package());
|
||||
return native_installation::install_or_update(native.as_ref(), npm_installed);
|
||||
}
|
||||
|
||||
let installed = runtime.refresh().ok_or_else(|| {
|
||||
format!(
|
||||
"Installation completed, but the Node.js runtime at {} could not be validated.",
|
||||
runtime.node_path.display()
|
||||
)
|
||||
})?;
|
||||
let installation = installed.mcp_installation.as_ref().ok_or_else(|| {
|
||||
format!(
|
||||
"Installation completed, but {} was not found under {}.",
|
||||
MCP_PACKAGE_NAME,
|
||||
installed.npm_root.display()
|
||||
)
|
||||
})?;
|
||||
Ok(format!("Successfully installed @dbx-app/mcp-server@{}", installation.package_version))
|
||||
if let Some(runtime) = resolve_node_runtime().filter(NodeRuntime::has_mcp_package) {
|
||||
return install_or_update_npm_mcp_server_sync(runtime);
|
||||
}
|
||||
native_installation::install_or_update(None, false)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn uninstall_mcp_server() -> Result<String, String> {
|
||||
pub async fn install_native_mcp_server() -> Result<String, String> {
|
||||
tauri::async_runtime::spawn_blocking(|| {
|
||||
let runtime = resolve_node_runtime().ok_or_else(|| {
|
||||
format!("Unable to resolve a compatible Node.js ({}) and npm runtime.", MCP_MIN_NODE_VERSION_REQUIREMENT)
|
||||
})?;
|
||||
if !runtime.has_mcp_package() {
|
||||
return Err(format!("{} is not installed in the detected Node.js runtime.", MCP_PACKAGE_NAME));
|
||||
}
|
||||
|
||||
let output = runtime.uninstall()?;
|
||||
if !output.success {
|
||||
let error_msg = if !output.stderr.is_empty() { output.stderr } else { output.stdout };
|
||||
return Err(format!("Uninstallation failed: {}", error_msg));
|
||||
}
|
||||
|
||||
if runtime.refresh().is_some_and(|refreshed| refreshed.has_mcp_package()) {
|
||||
return Err(format!("Uninstallation completed, but {} is still installed.", MCP_PACKAGE_NAME));
|
||||
}
|
||||
Ok(format!("Successfully uninstalled {}", MCP_PACKAGE_NAME))
|
||||
let native = native_installation::resolve();
|
||||
let npm_installed = resolve_node_runtime().is_some_and(|runtime| runtime.has_mcp_package());
|
||||
native_installation::install_or_update(native.as_ref(), npm_installed)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
fn install_or_update_npm_mcp_server_sync(runtime: NodeRuntime) -> Result<String, String> {
|
||||
let output = runtime.install_or_update()?;
|
||||
if !output.success {
|
||||
let error_msg = if !output.stderr.is_empty() { output.stderr } else { output.stdout };
|
||||
return Err(format!("Installation failed: {}", error_msg));
|
||||
}
|
||||
|
||||
let installed = runtime.refresh().ok_or_else(|| {
|
||||
format!(
|
||||
"Installation completed, but the Node.js runtime at {} could not be validated.",
|
||||
runtime.node_path.display()
|
||||
)
|
||||
})?;
|
||||
let installation = installed.mcp_installation.as_ref().ok_or_else(|| {
|
||||
format!(
|
||||
"Installation completed, but {} was not found under {}.",
|
||||
MCP_PACKAGE_NAME,
|
||||
installed.npm_root.display()
|
||||
)
|
||||
})?;
|
||||
Ok(format!("Successfully installed @dbx-app/mcp-server@{}", installation.package_version))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn uninstall_mcp_server() -> Result<String, String> {
|
||||
tauri::async_runtime::spawn_blocking(|| {
|
||||
if let Some(native) = native_installation::resolve() {
|
||||
return native.uninstall();
|
||||
}
|
||||
uninstall_npm_mcp_server_sync()
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn uninstall_npm_mcp_server() -> Result<String, String> {
|
||||
tauri::async_runtime::spawn_blocking(uninstall_npm_mcp_server_sync).await.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
fn uninstall_npm_mcp_server_sync() -> Result<String, String> {
|
||||
let runtime = resolve_node_runtime().ok_or_else(|| {
|
||||
format!("Unable to resolve a compatible Node.js ({}) and npm runtime.", MCP_MIN_NODE_VERSION_REQUIREMENT)
|
||||
})?;
|
||||
if !runtime.has_mcp_package() {
|
||||
return Err(format!("{} is not installed in the detected Node.js runtime.", MCP_PACKAGE_NAME));
|
||||
}
|
||||
|
||||
let output = runtime.uninstall()?;
|
||||
if !output.success {
|
||||
let error_msg = if !output.stderr.is_empty() { output.stderr } else { output.stdout };
|
||||
return Err(format!("Uninstallation failed: {}", error_msg));
|
||||
}
|
||||
|
||||
if runtime.refresh().is_some_and(|refreshed| refreshed.has_mcp_package()) {
|
||||
return Err(format!("Uninstallation completed, but {} is still installed.", MCP_PACKAGE_NAME));
|
||||
}
|
||||
Ok(format!("Successfully uninstalled {}", MCP_PACKAGE_NAME))
|
||||
}
|
||||
|
||||
async fn fetch_latest_mcp_version() -> Result<String, String> {
|
||||
let mut builder = reqwest::Client::builder().timeout(Duration::from_secs(10)).user_agent("dbx-mcp-status-checker");
|
||||
let proxy_url =
|
||||
@@ -424,6 +474,9 @@ pub(crate) async fn resolve_mcp_server_command() -> Result<(String, Vec<String>)
|
||||
}
|
||||
|
||||
fn resolve_mcp_server_command_sync() -> Option<(String, Vec<String>)> {
|
||||
if let Some(native) = native_installation::resolve() {
|
||||
return Some((path_string(&native.binary), Vec::new()));
|
||||
}
|
||||
let runtime = resolve_node_runtime();
|
||||
if let Some(command) = runtime.as_ref().and_then(mcp_command_for_runtime) {
|
||||
return Some(command);
|
||||
@@ -2009,7 +2062,6 @@ mod tests {
|
||||
native_bin_path: None,
|
||||
};
|
||||
let runtime = runtime_for_installation(installation, PathBuf::from("/node-root"));
|
||||
assert_eq!(runtime.update_command(), super::MCP_BUN_UPDATE_COMMAND);
|
||||
assert_eq!(runtime.uninstall_command(), super::MCP_BUN_UNINSTALL_COMMAND);
|
||||
}
|
||||
|
||||
@@ -2033,7 +2085,6 @@ mod tests {
|
||||
assert!(runtime.install_or_update().is_err());
|
||||
assert!(runtime.uninstall().is_err());
|
||||
// The UI still shows the bun command so the user can act manually.
|
||||
assert_eq!(runtime.update_command(), super::MCP_BUN_UPDATE_COMMAND);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2393,7 +2444,6 @@ mod tests {
|
||||
.unwrap();
|
||||
let runtime = runtime_for_installation(installation, npm_root);
|
||||
|
||||
assert_eq!(runtime.update_command(), super::MCP_INSTALL_COMMAND);
|
||||
assert_eq!(runtime.uninstall_command(), super::MCP_UNINSTALL_COMMAND);
|
||||
assert!(matches!(
|
||||
runtime.mcp_installation.as_ref().map(|installation| &installation.package_manager),
|
||||
@@ -2444,7 +2494,6 @@ mod tests {
|
||||
assert_eq!(installation.script_path, canonical_runtime_path(&fixture.script_path).unwrap());
|
||||
assert_eq!(installation.launcher_path, canonical_runtime_path(&fixture.launcher_path));
|
||||
assert_eq!(installation.native_bin_path, None);
|
||||
assert_eq!(runtime.update_command(), super::MCP_PNPM_UPDATE_COMMAND);
|
||||
assert_eq!(runtime.uninstall_command(), super::MCP_PNPM_UNINSTALL_COMMAND);
|
||||
assert!(matches!(
|
||||
installation.package_manager,
|
||||
@@ -2687,7 +2736,6 @@ mod tests {
|
||||
let installation = probed.mcp_installation.as_ref().unwrap();
|
||||
assert_eq!(installation.script_path, canonical_runtime_path(&fixture.script_path).unwrap());
|
||||
assert_eq!(installation.package_version, "0.4.71");
|
||||
assert_eq!(probed.update_command(), super::MCP_PNPM_UPDATE_COMMAND);
|
||||
assert_eq!(probed.uninstall_command(), super::MCP_PNPM_UNINSTALL_COMMAND);
|
||||
// Detection canonicalizes the launcher directory, so the reported launcher,
|
||||
// `PNPM_HOME`, `PATH`, and `--global-dir` values are all resolved paths; the
|
||||
|
||||
@@ -0,0 +1,384 @@
|
||||
use std::io::Write;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Output, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const HOMEBREW_FORMULA: &str = "t8y2/tap/dbx-mcp";
|
||||
#[cfg(not(windows))]
|
||||
const INSTALLER_SCRIPT: &str = include_str!("../../../docs/public/install-mcp.sh");
|
||||
#[cfg(windows)]
|
||||
const INSTALLER_SCRIPT: &str = include_str!("../../../docs/public/install-mcp.ps1");
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum NativeInstallationKind {
|
||||
Installer,
|
||||
Homebrew,
|
||||
}
|
||||
|
||||
pub(super) struct NativeInstallation {
|
||||
pub binary: PathBuf,
|
||||
pub version: Option<String>,
|
||||
kind: NativeInstallationKind,
|
||||
}
|
||||
|
||||
pub(super) fn resolve() -> Option<NativeInstallation> {
|
||||
if let Some(installation) = discover_standalone() {
|
||||
return Some(installation);
|
||||
}
|
||||
if cfg!(windows) {
|
||||
return None;
|
||||
}
|
||||
let home = home_directory()?;
|
||||
let mut directories = vec![
|
||||
PathBuf::from("/opt/homebrew/bin"),
|
||||
PathBuf::from("/usr/local/bin"),
|
||||
PathBuf::from("/home/linuxbrew/.linuxbrew/bin"),
|
||||
home.join(".linuxbrew/bin"),
|
||||
];
|
||||
if let Some(path) = std::env::var_os("PATH") {
|
||||
directories.extend(std::env::split_paths(&path));
|
||||
}
|
||||
directories.into_iter().find_map(|directory| discover_homebrew(&directory.join("dbx-mcp")))
|
||||
}
|
||||
|
||||
fn home_directory() -> Option<PathBuf> {
|
||||
std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from)
|
||||
}
|
||||
|
||||
fn discover_standalone() -> Option<NativeInstallation> {
|
||||
discover(&home_directory()?.join(".dbx/bin"))
|
||||
}
|
||||
|
||||
fn parse_version(value: &str) -> Option<String> {
|
||||
let value = value.trim().strip_prefix("dbx-mcp ").unwrap_or(value.trim());
|
||||
let parts: Vec<_> = value.split('.').collect();
|
||||
(parts.len() == 3 && parts.iter().all(|part| !part.is_empty() && part.bytes().all(|byte| byte.is_ascii_digit())))
|
||||
.then(|| value.to_string())
|
||||
}
|
||||
|
||||
fn binary_version(binary: &Path) -> Option<String> {
|
||||
let mut child = dbx_core::process::new_std_command(binary)
|
||||
.arg("--version")
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.ok()?;
|
||||
let started = Instant::now();
|
||||
loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) if status.success() => {
|
||||
let output = child.wait_with_output().ok()?;
|
||||
return parse_version(std::str::from_utf8(&output.stdout).ok()?);
|
||||
}
|
||||
Ok(Some(_)) => return None,
|
||||
Ok(None) if started.elapsed() < Duration::from_secs(2) => {
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
_ => {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn discover(directory: &Path) -> Option<NativeInstallation> {
|
||||
let binary = directory.join(if cfg!(windows) { "dbx-mcp.exe" } else { "dbx-mcp" });
|
||||
let version = None;
|
||||
let mut installation = discover_binary(binary, version, NativeInstallationKind::Installer)?;
|
||||
if installation.version.is_none() {
|
||||
installation.version =
|
||||
std::fs::read_to_string(directory.join(".dbx-mcp-version")).ok().and_then(|value| parse_version(&value));
|
||||
}
|
||||
Some(installation)
|
||||
}
|
||||
|
||||
fn discover_homebrew(binary: &Path) -> Option<NativeInstallation> {
|
||||
let resolved = binary.canonicalize().ok()?;
|
||||
let bin_directory = resolved.parent()?;
|
||||
let version_directory = bin_directory.parent()?;
|
||||
let formula_directory = version_directory.parent()?;
|
||||
if bin_directory.file_name()? != "bin"
|
||||
|| formula_directory.file_name()? != "dbx-mcp"
|
||||
|| formula_directory.parent()?.file_name()? != "Cellar"
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let version = parse_version(version_directory.file_name()?.to_str()?);
|
||||
discover_binary(binary.to_path_buf(), version, NativeInstallationKind::Homebrew)
|
||||
}
|
||||
|
||||
fn discover_binary(
|
||||
binary: PathBuf,
|
||||
fallback_version: Option<String>,
|
||||
kind: NativeInstallationKind,
|
||||
) -> Option<NativeInstallation> {
|
||||
if !binary.is_absolute() || !binary.is_file() {
|
||||
return None;
|
||||
}
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if binary.metadata().ok()?.permissions().mode() & 0o111 == 0 {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
let version = binary_version(&binary).or(fallback_version);
|
||||
Some(NativeInstallation { binary, version, kind })
|
||||
}
|
||||
|
||||
pub(super) fn install_command() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"powershell -NoProfile -Command \"irm https://dbxio.com/install-mcp.ps1 | iex\""
|
||||
} else {
|
||||
"curl -fsSL https://dbxio.com/install-mcp | sh"
|
||||
}
|
||||
}
|
||||
|
||||
impl NativeInstallation {
|
||||
pub fn source(&self) -> &'static str {
|
||||
match self.kind {
|
||||
NativeInstallationKind::Installer => "native",
|
||||
NativeInstallationKind::Homebrew => "homebrew",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn install_command(&self) -> &'static str {
|
||||
match self.kind {
|
||||
NativeInstallationKind::Installer => install_command(),
|
||||
NativeInstallationKind::Homebrew => "brew install t8y2/tap/dbx-mcp",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_command(&self) -> &'static str {
|
||||
match self.kind {
|
||||
NativeInstallationKind::Installer => install_command(),
|
||||
NativeInstallationKind::Homebrew => "brew upgrade t8y2/tap/dbx-mcp",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn uninstall_command(&self) -> String {
|
||||
if self.kind == NativeInstallationKind::Homebrew {
|
||||
return "brew uninstall t8y2/tap/dbx-mcp".to_string();
|
||||
}
|
||||
let marker = self.binary.with_file_name(".dbx-mcp-version");
|
||||
#[cfg(windows)]
|
||||
{
|
||||
format!(
|
||||
"Remove-Item -LiteralPath '{}', '{}'",
|
||||
self.binary.to_string_lossy().replace('\'', "''"),
|
||||
marker.to_string_lossy().replace('\'', "''")
|
||||
)
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
format!(
|
||||
"rm -f {} {}",
|
||||
super::shell_quote(&self.binary.to_string_lossy()),
|
||||
super::shell_quote(&marker.to_string_lossy())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn uninstall(&self) -> Result<String, String> {
|
||||
match self.kind {
|
||||
NativeInstallationKind::Installer => self.uninstall_standalone(),
|
||||
NativeInstallationKind::Homebrew => self.run_homebrew("uninstall"),
|
||||
}
|
||||
}
|
||||
|
||||
fn uninstall_standalone(&self) -> Result<String, String> {
|
||||
let expected = home_directory()
|
||||
.map(|home| home.join(".dbx/bin").join(if cfg!(windows) { "dbx-mcp.exe" } else { "dbx-mcp" }))
|
||||
.ok_or_else(|| "Unable to resolve the user home directory.".to_string())?;
|
||||
if self.binary != expected {
|
||||
return Err(format!("Refusing to remove unexpected native MCP path: {}", self.binary.display()));
|
||||
}
|
||||
std::fs::remove_file(&self.binary)
|
||||
.map_err(|error| format!("Failed to remove {}: {error}", self.binary.display()))?;
|
||||
let marker = self.binary.with_file_name(".dbx-mcp-version");
|
||||
if marker.exists() {
|
||||
std::fs::remove_file(&marker).map_err(|error| {
|
||||
format!("Removed the MCP binary, but failed to remove {}: {error}", marker.display())
|
||||
})?;
|
||||
}
|
||||
Ok(format!("Uninstalled native DBX MCP from {}", self.binary.display()))
|
||||
}
|
||||
|
||||
fn run_homebrew(&self, action: &str) -> Result<String, String> {
|
||||
let brew = self
|
||||
.binary
|
||||
.parent()
|
||||
.map(|directory| directory.join("brew"))
|
||||
.filter(|candidate| candidate.is_file())
|
||||
.or_else(|| super::locate_command("brew").map(PathBuf::from))
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Homebrew manages this installation, but brew was not found. Run: brew {action} {HOMEBREW_FORMULA}"
|
||||
)
|
||||
})?;
|
||||
let output = dbx_core::process::new_std_command(&brew)
|
||||
.args([action, HOMEBREW_FORMULA])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.map_err(|error| format!("Failed to run {}: {error}", brew.display()))?;
|
||||
require_success(output, &format!("brew {action}"))?;
|
||||
if action == "uninstall" {
|
||||
if discover_homebrew(&self.binary).is_some() {
|
||||
return Err("Homebrew completed, but dbx-mcp is still installed.".to_string());
|
||||
}
|
||||
return Ok("Uninstalled Homebrew DBX MCP".to_string());
|
||||
}
|
||||
let installation = discover_homebrew(&self.binary)
|
||||
.ok_or_else(|| "Homebrew completed, but the DBX MCP installation could not be validated.".to_string())?;
|
||||
Ok(format!(
|
||||
"Updated Homebrew DBX MCP{}",
|
||||
installation.version.as_deref().map(|version| format!(" to {version}")).unwrap_or_default()
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn install_or_update(existing: Option<&NativeInstallation>, npm_installed: bool) -> Result<String, String> {
|
||||
if let Some(existing) = existing.filter(|installation| installation.kind == NativeInstallationKind::Homebrew) {
|
||||
return existing.run_homebrew("upgrade");
|
||||
}
|
||||
|
||||
run_embedded_installer()?;
|
||||
let installation = discover_standalone()
|
||||
.ok_or_else(|| "The native installer completed, but ~/.dbx/bin/dbx-mcp could not be validated.".to_string())?;
|
||||
let action = if existing.is_some() { "Updated" } else { "Installed" };
|
||||
let mut message = format!(
|
||||
"{action} native DBX MCP{} at {}",
|
||||
installation.version.as_deref().map(|version| format!(" {version}")).unwrap_or_default(),
|
||||
installation.binary.display()
|
||||
);
|
||||
if existing.is_none() && npm_installed {
|
||||
message.push_str(". The existing npm installation was kept as a fallback; update external client configurations to use the native path.");
|
||||
}
|
||||
Ok(message)
|
||||
}
|
||||
|
||||
fn run_embedded_installer() -> Result<(), String> {
|
||||
#[cfg(windows)]
|
||||
let (program, arguments): (&str, &[&str]) =
|
||||
("powershell", &["-NoProfile", "-NonInteractive", "-ExecutionPolicy", "Bypass", "-Command", "-"]);
|
||||
#[cfg(not(windows))]
|
||||
let (program, arguments): (&str, &[&str]) = ("sh", &["-s"]);
|
||||
|
||||
let mut command = dbx_core::process::new_std_command(program);
|
||||
command.args(arguments).stdin(Stdio::piped()).stdout(Stdio::piped()).stderr(Stdio::piped());
|
||||
if ["HTTPS_PROXY", "https_proxy", "HTTP_PROXY", "http_proxy", "ALL_PROXY", "all_proxy"]
|
||||
.into_iter()
|
||||
.all(|name| std::env::var_os(name).is_none())
|
||||
{
|
||||
if let Some(proxy) = dbx_core::update::system_proxy_url() {
|
||||
command.env("HTTPS_PROXY", &proxy).env("HTTP_PROXY", &proxy).env("ALL_PROXY", proxy);
|
||||
}
|
||||
}
|
||||
let mut child =
|
||||
command.spawn().map_err(|error| format!("Failed to start the native MCP installer with {program}: {error}"))?;
|
||||
child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| "Failed to open the native MCP installer input.".to_string())?
|
||||
.write_all(INSTALLER_SCRIPT.as_bytes())
|
||||
.map_err(|error| format!("Failed to start the native MCP installer: {error}"))?;
|
||||
let output =
|
||||
child.wait_with_output().map_err(|error| format!("Failed to wait for the native MCP installer: {error}"))?;
|
||||
require_success(output, "Native MCP installer")
|
||||
}
|
||||
|
||||
fn require_success(output: Output, action: &str) -> Result<(), String> {
|
||||
if output.status.success() {
|
||||
return Ok(());
|
||||
}
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
let detail = if stderr.is_empty() { stdout } else { stderr };
|
||||
Err(if detail.is_empty() {
|
||||
format!("{action} failed with {}.", output.status)
|
||||
} else {
|
||||
format!("{action} failed: {detail}")
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_version_output_and_rejects_marker_commands() {
|
||||
assert_eq!(parse_version("dbx-mcp 0.4.96\n").as_deref(), Some("0.4.96"));
|
||||
assert_eq!(parse_version("0.4.95\n").as_deref(), Some("0.4.95"));
|
||||
for invalid in ["", "0.4", "0.4.1; rm", "0.4.1\n0.4.2", "dbx-mcp unknown"] {
|
||||
assert!(parse_version(invalid).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn marker_alone_is_not_an_installation() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
std::fs::write(directory.path().join(".dbx-mcp-version"), "0.4.96\n").unwrap();
|
||||
assert!(discover(directory.path()).is_none());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn native_version_precedes_marker_and_legacy_binary_uses_marker() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let binary = directory.path().join("dbx-mcp");
|
||||
std::fs::write(&binary, "#!/bin/sh\nprintf 'dbx-mcp 0.4.96\\n'\n").unwrap();
|
||||
std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
std::fs::write(directory.path().join(".dbx-mcp-version"), "0.4.95\n").unwrap();
|
||||
let installation = discover(directory.path()).unwrap();
|
||||
assert_eq!(installation.binary, binary);
|
||||
assert_eq!(installation.version.as_deref(), Some("0.4.96"));
|
||||
assert_eq!(installation.source(), "native");
|
||||
std::fs::write(&binary, "#!/bin/sh\nexit 1\n").unwrap();
|
||||
assert_eq!(discover(directory.path()).unwrap().version.as_deref(), Some("0.4.95"));
|
||||
std::fs::remove_file(directory.path().join(".dbx-mcp-version")).unwrap();
|
||||
assert!(discover(directory.path()).unwrap().version.is_none());
|
||||
std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
assert!(discover(directory.path()).is_none());
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn stalled_version_probe_times_out_and_uses_marker() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let binary = directory.path().join("dbx-mcp");
|
||||
std::fs::write(&binary, "#!/bin/sh\nexec sleep 30\n").unwrap();
|
||||
std::fs::set_permissions(&binary, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
std::fs::write(directory.path().join(".dbx-mcp-version"), "0.4.95\n").unwrap();
|
||||
let started = Instant::now();
|
||||
assert_eq!(discover(directory.path()).unwrap().version.as_deref(), Some("0.4.95"));
|
||||
assert!(started.elapsed() < Duration::from_secs(5));
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn homebrew_keeps_a_stable_path_and_its_own_management_commands() {
|
||||
use std::os::unix::fs::{symlink, PermissionsExt};
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let cellar_binary = directory.path().join("Cellar/dbx-mcp/0.4.96/bin/dbx-mcp");
|
||||
std::fs::create_dir_all(cellar_binary.parent().unwrap()).unwrap();
|
||||
std::fs::write(&cellar_binary, "#!/bin/sh\nexit 1\n").unwrap();
|
||||
std::fs::set_permissions(&cellar_binary, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let link = directory.path().join("dbx-mcp");
|
||||
symlink(&cellar_binary, &link).unwrap();
|
||||
let installation = discover_homebrew(&link).unwrap();
|
||||
assert_eq!(installation.binary, link);
|
||||
assert_eq!(installation.version.as_deref(), Some("0.4.96"));
|
||||
assert_eq!(installation.source(), "homebrew");
|
||||
assert_eq!(installation.install_command(), "brew install t8y2/tap/dbx-mcp");
|
||||
assert_eq!(installation.update_command(), "brew upgrade t8y2/tap/dbx-mcp");
|
||||
assert_eq!(installation.uninstall_command(), "brew uninstall t8y2/tap/dbx-mcp");
|
||||
assert!(discover_homebrew(Path::new("/bin/sh")).is_none());
|
||||
}
|
||||
}
|
||||
@@ -2657,7 +2657,9 @@ pub fn run() {
|
||||
commands::history::delete_history_entry,
|
||||
commands::mcp::check_mcp_server_status,
|
||||
commands::mcp::install_mcp_server,
|
||||
commands::mcp::install_native_mcp_server,
|
||||
commands::mcp::uninstall_mcp_server,
|
||||
commands::mcp::uninstall_npm_mcp_server,
|
||||
commands::update::check_for_updates,
|
||||
commands::update::fetch_changelog,
|
||||
commands::update::get_system_proxy_url,
|
||||
|
||||
Reference in New Issue
Block a user