From b8274363b813eae0ab7cd933ad2f7bdeb1f2cec0 Mon Sep 17 00:00:00 2001 From: t8y2 Date: Fri, 25 Sep 2026 12:37:16 +0800 Subject: [PATCH] feat(mcp): add native installs and resource discovery --- .github/scripts/dbx-mcp.rb.template | 33 ++ .../scripts/install-mcp-powershell.test.ps1 | 114 ++++++ .github/scripts/install-mcp.test.mjs | 201 +++++++++ .github/scripts/render-mcp-formula.mjs | 21 + .github/workflows/ci.yml | 7 + .github/workflows/mcp-release.yml | 20 +- .github/workflows/publish-packages.yml | 15 +- .../editor/EditorSettingsDialog.vue | 80 +++- .../components/layout/UpdateDialog.spec.ts | 2 +- .../__tests__/useComponentUpdates.spec.ts | 2 + .../__tests__/useMcpUpdateBadge.spec.ts | 3 + apps/desktop/src/i18n/locales/en.ts | 7 + apps/desktop/src/i18n/locales/zh-CN.ts | 7 + apps/desktop/src/i18n/locales/zh-TW.ts | 7 + .../__tests__/mcp/mcpConfigTemplates.spec.ts | 30 +- .../__tests__/mcp/mcpServerManagement.spec.ts | 14 + apps/desktop/src/lib/backend/api.ts | 2 + apps/desktop/src/lib/backend/http.ts | 10 + apps/desktop/src/lib/backend/tauri.ts | 10 + .../desktop/src/lib/mcp/mcpConfigTemplates.ts | 6 +- .../src/lib/updates/updatePreviewMock.ts | 2 + crates/dbx-mcp/src/main.rs | 4 + crates/dbx-mcp/src/server.rs | 236 ++++++++++- crates/dbx-mcp/tests/protocol.rs | 133 +++++- crates/dbx-mcp/tests/version.rs | 16 + docs/content/docs/mcp.cn.mdx | 44 +- docs/content/docs/mcp.mdx | 44 +- docs/lib/worker.test.ts | 28 +- docs/public/install-mcp.ps1 | 168 ++++++++ docs/public/install-mcp.sh | 152 +++++++ docs/worker.ts | 13 + docs/wrangler.json | 2 + packages/mcp-server/README.md | 26 ++ src-tauri/src/commands/mcp.rs | 192 +++++---- .../src/commands/mcp_native_installation.rs | 384 ++++++++++++++++++ src-tauri/src/lib.rs | 2 + 36 files changed, 1934 insertions(+), 103 deletions(-) create mode 100644 .github/scripts/dbx-mcp.rb.template create mode 100644 .github/scripts/install-mcp-powershell.test.ps1 create mode 100644 .github/scripts/install-mcp.test.mjs create mode 100644 .github/scripts/render-mcp-formula.mjs create mode 100644 crates/dbx-mcp/tests/version.rs create mode 100644 docs/public/install-mcp.ps1 create mode 100644 docs/public/install-mcp.sh create mode 100644 src-tauri/src/commands/mcp_native_installation.rs diff --git a/.github/scripts/dbx-mcp.rb.template b/.github/scripts/dbx-mcp.rb.template new file mode 100644 index 0000000000..09508a9620 --- /dev/null +++ b/.github/scripts/dbx-mcp.rb.template @@ -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 diff --git a/.github/scripts/install-mcp-powershell.test.ps1 b/.github/scripts/install-mcp-powershell.test.ps1 new file mode 100644 index 0000000000..ef159aeec9 --- /dev/null +++ b/.github/scripts/install-mcp-powershell.test.ps1 @@ -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 +} diff --git a/.github/scripts/install-mcp.test.mjs b/.github/scripts/install-mcp.test.mjs new file mode 100644 index 0000000000..77969dd3a1 --- /dev/null +++ b/.github/scripts/install-mcp.test.mjs @@ -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/); + } +}); diff --git a/.github/scripts/render-mcp-formula.mjs b/.github/scripts/render-mcp-formula.mjs new file mode 100644 index 0000000000..8c817d7f6e --- /dev/null +++ b/.github/scripts/render-mcp-formula.mjs @@ -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 "); +} +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); diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dcff8fe449..b156a660e0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' diff --git a/.github/workflows/mcp-release.yml b/.github/workflows/mcp-release.yml index 52624fe2f5..eed8639cef 100644 --- a/.github/workflows/mcp-release.yml +++ b/.github/workflows/mcp-release.yml @@ -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 diff --git a/.github/workflows/publish-packages.yml b/.github/workflows/publish-packages.yml index 7fbcf4fad9..a5b2b71a42 100644 --- a/.github/workflows/publish-packages.yml +++ b/.github/workflows/publish-packages.yml @@ -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." diff --git a/apps/desktop/src/components/editor/EditorSettingsDialog.vue b/apps/desktop/src/components/editor/EditorSettingsDialog.vue index 3fd1af7399..0a8da29f6c 100644 --- a/apps/desktop/src/components/editor/EditorSettingsDialog.vue +++ b/apps/desktop/src/components/editor/EditorSettingsDialog.vue @@ -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(() => { }; } 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(() => { 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;
- + +

+ {{ t("settings.mcpNativeMigrationHint") }} +

{{ mcpCommand }} @@ -9932,10 +9963,10 @@ LIMIT 100; -
-
+ +
+
+

+ {{ t("settings.mcpNpmFallbackHint") }} +

+ +
+
@@ -10378,7 +10422,7 @@ LIMIT 100; - {{ t("settings.mcpDetectionTiming") }} {{ t("settings.mcpNpmBoundary") }} + {{ t("settings.mcpDetectionTiming") }} {{ t("settings.mcpNativeManagementBoundary") }} diff --git a/apps/desktop/src/components/layout/UpdateDialog.spec.ts b/apps/desktop/src/components/layout/UpdateDialog.spec.ts index 76360ddd42..bfaa95c286 100644 --- a/apps/desktop/src/components/layout/UpdateDialog.spec.ts +++ b/apps/desktop/src/components/layout/UpdateDialog.spec.ts @@ -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", diff --git a/apps/desktop/src/composables/__tests__/useComponentUpdates.spec.ts b/apps/desktop/src/composables/__tests__/useComponentUpdates.spec.ts index ba95f4d321..9010d5fc9d 100644 --- a/apps/desktop/src/composables/__tests__/useComponentUpdates.spec.ts +++ b/apps/desktop/src/composables/__tests__/useComponentUpdates.spec.ts @@ -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, diff --git a/apps/desktop/src/composables/__tests__/useMcpUpdateBadge.spec.ts b/apps/desktop/src/composables/__tests__/useMcpUpdateBadge.spec.ts index b029a96342..48047ad937 100644 --- a/apps/desktop/src/composables/__tests__/useMcpUpdateBadge.spec.ts +++ b/apps/desktop/src/composables/__tests__/useMcpUpdateBadge.spec.ts @@ -32,7 +32,9 @@ function deferred(): Deferred { function makeStatus(update_available: boolean, overrides: Partial = {}): 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 { + 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()); diff --git a/apps/desktop/src/lib/__tests__/mcp/mcpServerManagement.spec.ts b/apps/desktop/src/lib/__tests__/mcp/mcpServerManagement.spec.ts index 4a52aaddf9..16486a9e26 100644 --- a/apps/desktop/src/lib/__tests__/mcp/mcpServerManagement.spec.ts +++ b/apps/desktop/src/lib/__tests__/mcp/mcpServerManagement.spec.ts @@ -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"))'); diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts index 9e367e720b..b842f139e3 100644 --- a/apps/desktop/src/lib/backend/api.ts +++ b/apps/desktop/src/lib/backend/api.ts @@ -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"); diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts index 4d06b38149..e582a24436 100644 --- a/apps/desktop/src/lib/backend/http.ts +++ b/apps/desktop/src/lib/backend/http.ts @@ -5200,7 +5200,9 @@ export async function fetchChangelog(lang?: string): Promise { 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 { throw new Error("MCP Server installation is only available in the desktop app."); } +export async function installNativeMcpServer(): Promise { + throw new Error("Native MCP Server installation is only available in the desktop app."); +} + export async function uninstallMcpServer(): Promise { throw new Error("MCP Server uninstallation is only available in the desktop app."); } +export async function uninstallNpmMcpServer(): Promise { + throw new Error("MCP Server npm fallback removal is only available in the desktop app."); +} + export async function getSystemProxyUrl(): Promise { return null; } diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts index e94b4a920a..e8fd7c5e6b 100644 --- a/apps/desktop/src/lib/backend/tauri.ts +++ b/apps/desktop/src/lib/backend/tauri.ts @@ -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 { return invoke("install_mcp_server"); } +export async function installNativeMcpServer(): Promise { + return invoke("install_native_mcp_server"); +} + export async function uninstallMcpServer(): Promise { return invoke("uninstall_mcp_server"); } +export async function uninstallNpmMcpServer(): Promise { + return invoke("uninstall_npm_mcp_server"); +} + export async function checkForUpdates(locale?: string, source?: UpdateDownloadSource): Promise { return invoke("check_for_updates", { locale, source }); } diff --git a/apps/desktop/src/lib/mcp/mcpConfigTemplates.ts b/apps/desktop/src/lib/mcp/mcpConfigTemplates.ts index 5780c3eeac..664e5a54a7 100644 --- a/apps/desktop/src/lib/mcp/mcpConfigTemplates.ts +++ b/apps/desktop/src/lib/mcp/mcpConfigTemplates.ts @@ -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 { diff --git a/apps/desktop/src/lib/updates/updatePreviewMock.ts b/apps/desktop/src/lib/updates/updatePreviewMock.ts index 9ede27e95f..42cabb046e 100644 --- a/apps/desktop/src/lib/updates/updatePreviewMock.ts +++ b/apps/desktop/src/lib/updates/updatePreviewMock.ts @@ -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", diff --git a/crates/dbx-mcp/src/main.rs b/crates/dbx-mcp/src/main.rs index e5575a33d8..994ae7e1bd 100644 --- a/crates/dbx-mcp/src/main.rs +++ b/crates/dbx-mcp/src/main.rs @@ -8,6 +8,10 @@ use rmcp::ServiceExt; #[tokio::main] async fn main() -> Result<(), Box> { + 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 = if let Ok(base_url) = std::env::var("DBX_WEB_URL") { Arc::new( diff --git a/crates/dbx-mcp/src/server.rs b/crates/dbx-mcp/src/server.rs index f09d551658..f6ab37a76a 100644 --- a/crates/dbx-mcp/src/server.rs +++ b/crates/dbx-mcp/src/server.rs @@ -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, schema: Option }, + TableSchema { connection_id: String, database: Option, schema: Option, 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, + _context: rmcp::service::RequestContext, + ) -> Result { + 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, + _context: rmcp::service::RequestContext, + ) -> Result { + 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, + ) -> Result { + 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 { + 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::>() }; + 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, 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, name: &str) -> Option { + parameters.get(name).map(|value| value.trim()).filter(|value| !value.is_empty()).map(str::to_string) +} + +fn required_resource_parameter(parameters: &HashMap, name: &str) -> Result { + 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 { + 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 { + let output = result + .content + .iter() + .filter_map(|content| content.as_text().map(|text| text.text.as_str())) + .collect::>() + .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) -> 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>>, diff --git a/crates/dbx-mcp/tests/protocol.rs b/crates/dbx-mcp/tests/protocol.rs index 1186fab5c1..adfc5ea3c8 100644 --- a/crates/dbx-mcp/tests/protocol.rs +++ b/crates/dbx-mcp/tests/protocol.rs @@ -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, String> { + Ok(vec!["analytics".to_string(), "reporting".to_string()]) + } + + async fn list_tables( + &self, + _connection: &ConnectionConfig, + _database: &str, + _schema: &str, + ) -> Result, 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, 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>, 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::>(); + 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() { diff --git a/crates/dbx-mcp/tests/version.rs b/crates/dbx-mcp/tests/version.rs new file mode 100644 index 0000000000..c96d4e3f66 --- /dev/null +++ b/crates/dbx-mcp/tests/version.rs @@ -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()); +} diff --git a/docs/content/docs/mcp.cn.mdx b/docs/content/docs/mcp.cn.mdx index 7d32ae3861..9e1a0eaddb 100644 --- a/docs/content/docs/mcp.cn.mdx +++ b/docs/content/docs/mcp.cn.mdx @@ -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 快速开始 @@ -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 连接范围及工具白名单,允许读取只读连接和生产连接,不提交消费位点。 diff --git a/docs/content/docs/mcp.mdx b/docs/content/docs/mcp.mdx index 6e326406b1..8797589a20 100644 --- a/docs/content/docs/mcp.mdx +++ b/docs/content/docs/mcp.mdx @@ -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 @@ -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. diff --git a/docs/lib/worker.test.ts b/docs/lib/worker.test.ts index 270731eb41..ecbd7c095a 100644 --- a/docs/lib/worker.test.ts +++ b/docs/lib/worker.test.ts @@ -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("not found", { 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(), //); + } + } +}); 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")); diff --git a/docs/public/install-mcp.ps1 b/docs/public/install-mcp.ps1 new file mode 100644 index 0000000000..f16da3ed88 --- /dev/null +++ b/docs/public/install-mcp.ps1 @@ -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 diff --git a/docs/public/install-mcp.sh b/docs/public/install-mcp.sh new file mode 100644 index 0000000000..af189cfe37 --- /dev/null +++ b/docs/public/install-mcp.sh @@ -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) || 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 diff --git a/docs/worker.ts b/docs/worker.ts index e656080ada..d1b0ff0ff2 100644 --- a/docs/worker.ts +++ b/docs/worker.ts @@ -633,6 +633,19 @@ export class IssueSubmissionLimiter { export default { async fetch(request: Request, env: Env): Promise { 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. diff --git a/docs/wrangler.json b/docs/wrangler.json index 3566b0441e..ca85f64ea3 100644 --- a/docs/wrangler.json +++ b/docs/wrangler.json @@ -7,6 +7,8 @@ "binding": "ASSETS", "not_found_handling": "404-page", "run_worker_first": [ + "/install-mcp", + "/install-mcp.ps1", "/api/*", "/cn/plugins/*", "/en/plugins/*", diff --git a/packages/mcp-server/README.md b/packages/mcp-server/README.md index 9526d01c44..dcd017ba02 100644 --- a/packages/mcp-server/README.md +++ b/packages/mcp-server/README.md @@ -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` diff --git a/src-tauri/src/commands/mcp.rs b/src-tauri/src/commands/mcp.rs index 83bb041ebc..02624a08c9 100644 --- a/src-tauri/src/commands/mcp.rs +++ b/src-tauri/src/commands/mcp.rs @@ -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, pub npm_available: bool, + pub npm_installed: bool, pub node_path: Option, pub node_version: Option, pub current_version: Option, @@ -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 { 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 Result Result { 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 { +pub async fn install_native_mcp_server() -> Result { 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 { + 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 { + 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 { + tauri::async_runtime::spawn_blocking(uninstall_npm_mcp_server_sync).await.map_err(|e| e.to_string())? +} + +fn uninstall_npm_mcp_server_sync() -> Result { + 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 { 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) } fn resolve_mcp_server_command_sync() -> Option<(String, Vec)> { + 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 diff --git a/src-tauri/src/commands/mcp_native_installation.rs b/src-tauri/src/commands/mcp_native_installation.rs new file mode 100644 index 0000000000..c341ecac38 --- /dev/null +++ b/src-tauri/src/commands/mcp_native_installation.rs @@ -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, + kind: NativeInstallationKind, +} + +pub(super) fn resolve() -> Option { + 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 { + std::env::var_os(if cfg!(windows) { "USERPROFILE" } else { "HOME" }).map(PathBuf::from) +} + +fn discover_standalone() -> Option { + discover(&home_directory()?.join(".dbx/bin")) +} + +fn parse_version(value: &str) -> Option { + 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 { + 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 { + 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 { + 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, + kind: NativeInstallationKind, +) -> Option { + 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 { + match self.kind { + NativeInstallationKind::Installer => self.uninstall_standalone(), + NativeInstallationKind::Homebrew => self.run_homebrew("uninstall"), + } + } + + fn uninstall_standalone(&self) -> Result { + 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 { + 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 { + 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()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 2e0262bb28..7ed71d9dbe 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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,