Skip to content

Commit 9c05ef1

Browse files
committed
feat(desktop): in-app MCP popup to connect Duckle to an AI
Add a small MCP button (robot icon) to the designer top bar that opens a compact popup for connecting Duckle to an MCP client. It bundles the duckle-mcp server with the app and fills in the real resolved paths: - duckle-mcp is embedded into the desktop binary (build.rs include_bytes!, same mechanism as the runner) and extracted on demand to an app-data dir, with the runner written alongside it so the server resolves it for builds. - mcp_connection_info stages the server and returns the paths plus a ready-to-paste `claude mcp add` command and an mcpServers JSON config. - connect_claude_code runs `claude mcp add duckle ...` in one click (cmd raw_arg on Windows to resolve the claude.cmd shim); falls back to copy if the CLI is absent. - McpModal shows status (bundled / DuckDB found), the one-click connect, copy buttons for the command + config, and per-client guidance. - CI / release / GitLab now build + stage duckle-mcp alongside the runner so the desktop embed has it. Docs: the in-app popup is the easiest connect path in docs/current/mcp.md.
1 parent 9976690 commit 9c05ef1

10 files changed

Lines changed: 600 additions & 23 deletions

File tree

.github/workflows/ci.yml

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -72,20 +72,22 @@ jobs:
7272
run: |
7373
"$DUCKLE_DUCKDB_BIN" :memory: -c "INSTALL postgres; INSTALL mysql; INSTALL sqlite; INSTALL excel; INSTALL httpfs; INSTALL iceberg; INSTALL delta; INSTALL ducklake; INSTALL vss; INSTALL fts; INSTALL inet;"
7474
75-
# The desktop crate embeds duckle-runner at compile time via build.rs
76-
# include_bytes!, so the runner binary must exist before anything that
77-
# compiles apps/desktop. Build it in the dev profile so the engine
75+
# The desktop crate embeds duckle-runner AND duckle-mcp at compile time
76+
# via build.rs include_bytes!, so both binaries must exist before anything
77+
# that compiles apps/desktop. Build them in the dev profile so the engine
7878
# compilation is shared with the cargo test below.
79-
- name: Build + stage duckle-runner (embedded into the desktop app)
79+
- name: Build + stage duckle-runner + duckle-mcp (embedded into the desktop app)
8080
shell: bash
8181
run: |
8282
set -e
83-
cargo build -p duckle-runner
83+
cargo build -p duckle-runner -p duckle-mcp
8484
mkdir -p apps/desktop/bin
8585
if [ "$RUNNER_OS" = "Windows" ]; then
8686
cp target/debug/duckle-runner.exe apps/desktop/bin/duckle-runner.exe
87+
cp target/debug/duckle-mcp.exe apps/desktop/bin/duckle-mcp.exe
8788
else
8889
cp target/debug/duckle-runner apps/desktop/bin/duckle-runner
90+
cp target/debug/duckle-mcp apps/desktop/bin/duckle-mcp
8991
fi
9092
9193
- name: Test
@@ -129,15 +131,16 @@ jobs:
129131
sudo apt-get install -y \
130132
libwebkit2gtk-4.1-dev libgtk-3-dev \
131133
libayatana-appindicator3-dev librsvg2-dev libsoup-3.0-dev
132-
# Build + stage duckle-runner first: the desktop crate embeds it at
133-
# compile time (build.rs include_bytes!). Release profile so the engine
134-
# build is shared with the desktop release build below.
135-
- name: Build + stage duckle-runner (embedded into the desktop app)
134+
# Build + stage duckle-runner + duckle-mcp first: the desktop crate embeds
135+
# both at compile time (build.rs include_bytes!). Release profile so the
136+
# engine build is shared with the desktop release build below.
137+
- name: Build + stage duckle-runner + duckle-mcp (embedded into the desktop app)
136138
run: |
137139
set -e
138-
cargo build --release -p duckle-runner
140+
cargo build --release -p duckle-runner -p duckle-mcp
139141
mkdir -p apps/desktop/bin
140142
cp target/release/duckle-runner apps/desktop/bin/duckle-runner
143+
cp target/release/duckle-mcp apps/desktop/bin/duckle-mcp
141144
- name: Build raw Duckle binary (release workflow command)
142145
run: cargo build --release --manifest-path apps/desktop/Cargo.toml --features custom-protocol
143146
- name: Verify embedded frontend (not devUrl) is in binary

.github/workflows/release.yml

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -97,22 +97,24 @@ jobs:
9797
libwebkit2gtk-4.1-dev libgtk-3-dev \
9898
libayatana-appindicator3-dev librsvg2-dev libsoup-3.0-dev
9999
100-
- name: Build duckle-runner (bundled into the app)
100+
- name: Build duckle-runner + duckle-mcp (bundled into the app)
101101
shell: bash
102102
run: |
103103
set -e
104104
if [ -n "${{ matrix.target }}" ]; then
105-
cargo build --profile release-runner --target "${{ matrix.target }}" -p duckle-runner
106-
src="target/${{ matrix.target }}/release-runner/duckle-runner"
105+
cargo build --profile release-runner --target "${{ matrix.target }}" -p duckle-runner -p duckle-mcp
106+
base="target/${{ matrix.target }}/release-runner"
107107
else
108-
cargo build --profile release-runner -p duckle-runner
109-
src="target/release-runner/duckle-runner"
108+
cargo build --profile release-runner -p duckle-runner -p duckle-mcp
109+
base="target/release-runner"
110110
fi
111111
mkdir -p apps/desktop/bin
112112
if [ "${{ runner.os }}" = "Windows" ]; then
113-
cp "$src.exe" apps/desktop/bin/duckle-runner.exe
113+
cp "$base/duckle-runner.exe" apps/desktop/bin/duckle-runner.exe
114+
cp "$base/duckle-mcp.exe" apps/desktop/bin/duckle-mcp.exe
114115
else
115-
cp "$src" apps/desktop/bin/duckle-runner
116+
cp "$base/duckle-runner" apps/desktop/bin/duckle-runner
117+
cp "$base/duckle-mcp" apps/desktop/bin/duckle-mcp
116118
fi
117119
118120
- name: Build raw Duckle binary

.gitlab-ci.yml

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -138,12 +138,13 @@ desktop-build:linux:
138138
- npm --prefix frontend ci
139139
script:
140140
- npm --prefix frontend run build
141-
# Build + stage duckle-runner first: the desktop crate embeds it at
142-
# compile time (build.rs include_bytes!). Release profile so the engine
143-
# build is shared with the desktop release build below.
144-
- cargo build --release -p duckle-runner
141+
# Build + stage duckle-runner + duckle-mcp first: the desktop crate embeds
142+
# both at compile time (build.rs include_bytes!). Release profile so the
143+
# engine build is shared with the desktop release build below.
144+
- cargo build --release -p duckle-runner -p duckle-mcp
145145
- mkdir -p apps/desktop/bin
146146
- cp target/release/duckle-runner apps/desktop/bin/duckle-runner
147+
- cp target/release/duckle-mcp apps/desktop/bin/duckle-mcp
147148
- cargo build --release --manifest-path apps/desktop/Cargo.toml --features custom-protocol
148149
# Verify the embedded frontend chunk is actually in the binary. If
149150
# tauri-codegen embedded devUrl instead this would be absent.

apps/desktop/build.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,59 @@ fn main() {
2121
println!("cargo:rerun-if-changed=.duckle-always-restamp-build-epoch");
2222

2323
embed_runner();
24+
embed_mcp();
2425

2526
tauri_build::build()
2627
}
2728

29+
/// Locate a freshly built `duckle-mcp` and expose its bytes to lib.rs via
30+
/// include_bytes!(env!("DUCKLE_EMBEDDED_MCP")). Unlike the runner (required for
31+
/// Build Pipeline), the MCP server is optional: when it is not staged we embed
32+
/// an empty file so the desktop still builds, and the in-app MCP popup reports
33+
/// that this build carries no bundled server. CI / release stage it for real.
34+
fn embed_mcp() {
35+
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
36+
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR");
37+
let target_os = std::env::var("CARGO_CFG_TARGET_OS").unwrap_or_default();
38+
let name = if target_os == "windows" {
39+
"duckle-mcp.exe"
40+
} else {
41+
"duckle-mcp"
42+
};
43+
44+
let staged = std::path::Path::new(&manifest_dir).join("bin").join(name);
45+
let profile_dir = std::path::Path::new(&out_dir)
46+
.ancestors()
47+
.nth(3)
48+
.map(|p| p.join(name));
49+
let source = if staged.exists() {
50+
Some(staged)
51+
} else {
52+
profile_dir.filter(|p| p.exists())
53+
};
54+
55+
let dst = std::path::Path::new(&out_dir).join("embedded-mcp.bin");
56+
match source {
57+
Some(src) => {
58+
std::fs::copy(&src, &dst)
59+
.unwrap_or_else(|e| panic!("copy {} -> {}: {}", src.display(), dst.display(), e));
60+
println!("cargo:rerun-if-changed={}", src.display());
61+
}
62+
None => {
63+
std::fs::write(&dst, [])
64+
.unwrap_or_else(|e| panic!("write empty embedded-mcp: {}", e));
65+
println!(
66+
"cargo:warning=duckle-mcp not staged (apps/desktop/bin/{name}); the in-app MCP popup will report no bundled server. Stage it: cargo build --profile release-runner -p duckle-mcp"
67+
);
68+
}
69+
}
70+
println!("cargo:rustc-env=DUCKLE_EMBEDDED_MCP={}", dst.display());
71+
println!(
72+
"cargo:rerun-if-changed={}",
73+
std::path::Path::new(&manifest_dir).join("bin").join(name).display()
74+
);
75+
}
76+
2877
/// Locate a freshly built `duckle-runner` and expose its bytes to lib.rs via
2978
/// include_bytes!(env!("DUCKLE_EMBEDDED_RUNNER")). The runner is captured at
3079
/// desktop-compile time, so developers must build duckle-runner BEFORE (or

0 commit comments

Comments
 (0)