From 9b402a73a3f3cb3de9ed957aa6b75a1502e3e5c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Orell=20B=C3=BChler?= Date: Sun, 23 Aug 2026 17:54:56 +0200 Subject: [PATCH 1/4] migrate the MCP server to rmcp 3.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rmcp 3 renamed `Content` to `ContentBlock`; the constructors and `CallToolResult::success` are otherwise unchanged, so the visual tools' `image_result` helper is the only call site that moved. Two things the rename does not cover: `rmcp` now validates the inbound `Host` header against an allow-list that defaults to loopback, to blunt DNS rebinding against local servers. But `KERF_MCP_ADDR` exists to move the server *off* loopback, so the stock default would have made every such override reject the clients it just enabled. `allowed_hosts` derives the list from the bind address instead: a concrete address joins the loopback defaults, while a wildcard bind cannot be enumerated — the client's `Host` is whichever of this machine's addresses it arrived on — so it yields the empty "allow any" list. Tool schemas now come from schemars 1, which emits draft 2020-12 with `$defs`. The generated surface is equivalent, but nothing failed to compile when it changed, so the contract is now pinned by tests: every tool keeps a description and an object schema, `Option` parameters stay out of `required` (a great many tools document a field as "omit to ..."), and an image result still serializes as a caption plus bare base64 under `data` rather than a `data:` URL. Verified against a running app: initialize, all 78 tools listed, read / mutating / error / staging round-trips, and both host-validation paths. --- CLAUDE.md | 10 ++- Cargo.lock | 83 ++++++++++++----- Cargo.toml | 2 +- README.md | 2 +- crates/kerf-app/src/mcp.rs | 179 +++++++++++++++++++++++++++++++++++-- 5 files changed, 243 insertions(+), 33 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 00555bf..3b911bf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -336,9 +336,15 @@ no editing logic in the adapter. ### embedded MCP server (`crates/kerf-app/src/mcp.rs`) The app **is** the MCP server — there is no separate binary. `mcp::serve` hosts the -tools over `rmcp` 1.7's **streamable-HTTP** transport (`StreamableHttpService` + +tools over `rmcp` 3.1's **streamable-HTTP** transport (`StreamableHttpService` + `LocalSessionManager`, nested into an `axum` router) on `127.0.0.1:7777/mcp` -(`KERF_MCP_ADDR` overrides). It is spawned from `lib.rs`'s Tauri `.setup` hook on +(`KERF_MCP_ADDR` overrides). rmcp validates the inbound **`Host`** header against +an allow-list that defaults to loopback (a DNS-rebinding guard), which would make +every `KERF_MCP_ADDR` override reject its own clients — so `allowed_hosts` (pure + +unit-tested) derives the list from the bind address: a concrete address is added to +the loopback defaults, and a wildcard bind (`0.0.0.0` / `[::]`) can't be enumerated +at all, so it yields an empty list, rmcp's "allow any". +It is spawned from `lib.rs`'s Tauri `.setup` hook on `tauri::async_runtime` and shares the **same** `Arc>` the Tauri commands hold, so the agent edits the project the user has open. Patterns that matter if you edit it: `#[tool_router]` on the impl + `#[tool_handler]` on `impl ServerHandler` — **no diff --git a/Cargo.lock b/Cargo.lock index bc9c6d3..0a65af3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -760,8 +760,18 @@ version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.23.0", + "darling_macro 0.23.0", +] + +[[package]] +name = "darling" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" +dependencies = [ + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -777,17 +787,41 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "darling_core" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 3.0.3", +] + [[package]] name = "darling_macro" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core", + "darling_core 0.23.0", "quote", "syn 2.0.118", ] +[[package]] +name = "darling_macro" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" +dependencies = [ + "darling_core 0.24.1", + "quote", + "syn 3.0.3", +] + [[package]] name = "dbus" version = "0.9.11" @@ -868,7 +902,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1060,7 +1094,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2431,7 +2465,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2480,7 +2514,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -3287,18 +3321,19 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.7.0" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0810a9f717d9828f475fe1f629f4c305c8464b7f496c3a854b58d29e65f4058e" +checksum = "1a15bc53261a9dc37e105df006e4656c598379a8f9581f8950debb130f27a7cf" dependencies = [ "async-trait", - "base64 0.22.1", + "base64 0.23.1", "bytes", "chrono", "futures", "http", "http-body", "http-body-util", + "indexmap 2.14.0", "pastey", "pin-project-lite", "rand", @@ -3318,15 +3353,15 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "1.7.0" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6aefac48c364756e97f04c0401ba3231e8607882c7c1d92da0437dc16307904d" +checksum = "a85d45508e9b4ba024fe996c2638799635d75b6dd0ba8f32ccf08f8026f0c780" dependencies = [ - "darling", + "darling 0.24.1", "proc-macro2", "quote", "serde_json", - "syn 2.0.118", + "syn 3.0.3", ] [[package]] @@ -3385,7 +3420,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3442,7 +3477,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3770,7 +3805,7 @@ version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ - "darling", + "darling 0.23.0", "proc-macro2", "quote", "syn 2.0.118", @@ -3905,7 +3940,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3970,9 +4005,9 @@ dependencies = [ [[package]] name = "sse-stream" -version = "0.2.3" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3962b63f038885f15bce2c6e02c0e7925c072f1ac86bb60fd44c5c6b762fb72" +checksum = "c123f296ade4ec4b8b0f6162116e6629f5146922ca5ab40ca9d3c2e73ab4761e" dependencies = [ "bytes", "futures-util", @@ -4519,7 +4554,7 @@ dependencies = [ "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4956,7 +4991,7 @@ dependencies = [ "png 0.18.1", "serde", "thiserror 2.0.20", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4985,7 +5020,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5441,7 +5476,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6682ae9..40211e2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -45,7 +45,7 @@ fontdb = "0.24" ffmpeg-next = "9.0" # MCP — official Rust SDK (streamable-HTTP server transport, hosted by kerf-app) -rmcp = { version = "1.7", features = ["server", "macros", "transport-streamable-http-server"] } +rmcp = { version = "3.1", features = ["server", "macros", "transport-streamable-http-server"] } schemars = "1.2" # HTTP server for the embedded MCP endpoint (matches rmcp's tower/axum 0.8) diff --git a/README.md b/README.md index f040e3a..0c0d90e 100644 --- a/README.md +++ b/README.md @@ -281,7 +281,7 @@ kerf/ | Styling | Tailwind CSS **4** (CSS config) + design tokens | | Media | FFmpeg binaries (always) · `ffmpeg-next` **8.1** (optional libav) | | Persistence | `rusqlite` (bundled SQLite) — one `.kerf` file | -| MCP | `rmcp` **1.7** (streamable-HTTP transport) | +| MCP | `rmcp` **3.1** (streamable-HTTP transport) | The engine's export and timeline-still paths are **pure and unit-tested** — clip positions, gaps, track layering, effects, keyframes, overlays, ducking and loudnorm all diff --git a/crates/kerf-app/src/mcp.rs b/crates/kerf-app/src/mcp.rs index 89aa9e1..7c3f671 100644 --- a/crates/kerf-app/src/mcp.rs +++ b/crates/kerf-app/src/mcp.rs @@ -19,8 +19,10 @@ use kerf_core::{ TextKeyframe, Transition, TransitionKind, VideoEffect, }; use rmcp::handler::server::wrapper::Parameters; -use rmcp::model::{CallToolResult, Content, ServerCapabilities, ServerInfo}; -use rmcp::transport::streamable_http_server::{session::local::LocalSessionManager, StreamableHttpService}; +use rmcp::model::{CallToolResult, ContentBlock, ServerCapabilities, ServerInfo}; +use rmcp::transport::streamable_http_server::{ + session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService, +}; use rmcp::{schemars, tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler}; use serde::Serialize; use tauri::{AppHandle, Emitter}; @@ -1729,6 +1731,45 @@ pub fn endpoint_url() -> String { format!("http://{}/mcp", bind_addr()) } +/// The `Host` headers this server will accept. +/// +/// rmcp validates the inbound `Host` against an allow-list that defaults to +/// loopback, to blunt DNS-rebinding attacks against locally running servers. +/// `KERF_MCP_ADDR` exists to move the server *off* loopback, so the bare +/// default would make every such override reject its own clients. Extend the +/// list with whatever the override names: a concrete address is added as-is, +/// while a wildcard bind (`0.0.0.0` / `[::]`) cannot be enumerated at all — +/// the client's `Host` is whichever of this machine's addresses it reached us +/// on — so it yields an empty list, which is rmcp's documented "allow any". +fn allowed_hosts(addr: &str) -> Vec { + let defaults = ["localhost", "127.0.0.1", "::1"]; + let mut hosts: Vec = defaults.iter().map(|h| h.to_string()).collect(); + let Some(host) = host_of(addr) else { + return hosts; + }; + if host.parse::().is_ok_and(|ip| ip.is_unspecified()) { + return Vec::new(); + } + if !hosts.iter().any(|h| h.eq_ignore_ascii_case(&host)) { + hosts.push(host); + } + hosts +} + +/// The host part of a `host:port` bind address, tolerating a bracketed IPv6 +/// literal and a missing port. +fn host_of(addr: &str) -> Option { + if let Ok(sock) = addr.parse::() { + return Some(sock.ip().to_string()); + } + let host = match addr.rsplit_once(':') { + Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host, + _ => addr, + }; + let host = host.trim().trim_start_matches('[').trim_end_matches(']'); + (!host.is_empty()).then(|| host.to_string()) +} + /// Serve the MCP tools over streamable HTTP at `/mcp`, sharing `project` with /// the Tauri commands. Runs until the process exits. pub async fn serve(project: Arc>, app: AppHandle) -> anyhow::Result<()> { @@ -1737,7 +1778,7 @@ pub async fn serve(project: Arc>, app: AppHandle) -> anyhow::Resu let service = StreamableHttpService::new( move || Ok(KerfMcp::new(project.clone(), app.clone())), LocalSessionManager::default().into(), - Default::default(), + StreamableHttpServerConfig::default().with_allowed_hosts(allowed_hosts(&addr)), ); let router = axum::Router::new().nest_service("/mcp", service); @@ -1790,7 +1831,7 @@ fn core_err(e: kerf_core::Error) -> McpError { /// not a `data:` URL). fn image_result(caption: String, jpeg: Vec) -> CallToolResult { let b64 = base64::engine::general_purpose::STANDARD.encode(&jpeg); - CallToolResult::success(vec![Content::text(caption), Content::image(b64, "image/jpeg")]) + CallToolResult::success(vec![ContentBlock::text(caption), ContentBlock::image(b64, "image/jpeg")]) } /// Format a seconds offset as `mm:ss.mmm` for frame / contact-sheet captions. @@ -1809,7 +1850,7 @@ fn json(value: &T) -> Result { #[cfg(test)] mod tests { - use super::fmt_ts; + use super::{allowed_hosts, fmt_ts, image_result, KerfMcp}; #[test] fn fmt_ts_carries_at_minute_boundaries() { @@ -1822,4 +1863,132 @@ mod tests { assert_eq!(fmt_ts(59.9994), "00:59.999"); assert_eq!(fmt_ts(125.25), "02:05.250"); } + + /// Every tool the agent can call must reach it with a description and an + /// object input schema. The router is built by the `tool_router` macro, so + /// this is what catches an rmcp upgrade silently changing how the tool + /// surface is generated. + #[test] + fn every_tool_has_a_description_and_object_schema() { + let tools = KerfMcp::tool_router().list_all(); + assert!(tools.len() > 50, "expected the full tool surface, got {}", tools.len()); + + for tool in &tools { + let description = tool.description.as_deref().unwrap_or_default(); + assert!(!description.is_empty(), "tool `{}` has no description", tool.name); + assert_eq!( + tool.input_schema.get("type").and_then(|t| t.as_str()), + Some("object"), + "tool `{}` input schema is not an object", + tool.name + ); + } + } + + /// `Option` parameters must stay out of `required` — a great many tools + /// document a field as "omit to …", and a schema generator that started + /// requiring them would break that contract without failing to compile. + #[test] + fn optional_parameters_are_not_required() { + let tools = KerfMcp::tool_router().list_all(); + let add_clip = tools + .iter() + .find(|t| t.name == "add_clip_to_timeline") + .expect("add_clip_to_timeline is registered"); + + let required: Vec<&str> = add_clip + .input_schema + .get("required") + .and_then(|r| r.as_array()) + .map(|r| r.iter().filter_map(|v| v.as_str()).collect()) + .unwrap_or_default(); + + // Documented as required by the tool. + assert!(required.contains(&"asset_id"), "asset_id should be required: {required:?}"); + assert!(required.contains(&"source_in"), "source_in should be required: {required:?}"); + // Documented as "omit to auto-select" / "omit to append". + assert!(!required.contains(&"track_id"), "track_id must stay optional: {required:?}"); + assert!( + !required.contains(&"timeline_start"), + "timeline_start must stay optional: {required:?}" + ); + + let properties = add_clip + .input_schema + .get("properties") + .and_then(|p| p.as_object()) + .expect("input schema has properties"); + for field in ["asset_id", "track_id", "source_in", "source_out", "timeline_start"] { + assert!(properties.contains_key(field), "`{field}` missing from schema"); + } + } + /// The default bind is loopback, which rmcp's own defaults already cover. + #[test] + fn loopback_binds_keep_the_default_allow_list() { + for addr in ["127.0.0.1:7777", "localhost:7777", "[::1]:7777"] { + let hosts = allowed_hosts(addr); + assert!(hosts.iter().any(|h| h == "127.0.0.1"), "{addr} -> {hosts:?}"); + assert!(hosts.iter().any(|h| h == "localhost"), "{addr} -> {hosts:?}"); + assert!(hosts.iter().any(|h| h == "::1"), "{addr} -> {hosts:?}"); + // No duplicates from re-adding a host the defaults already list. + assert_eq!(hosts.len(), 3, "{addr} -> {hosts:?}"); + } + } + + /// `KERF_MCP_ADDR` pointed at a concrete non-loopback address has to allow + /// that address, or the override rejects every client it just enabled. + #[test] + fn a_concrete_override_is_allowed() { + let hosts = allowed_hosts("192.168.1.5:7777"); + assert!(hosts.iter().any(|h| h == "192.168.1.5"), "{hosts:?}"); + // The loopback defaults survive alongside it. + assert!(hosts.iter().any(|h| h == "127.0.0.1"), "{hosts:?}"); + + let named = allowed_hosts("kerf.local:7777"); + assert!(named.iter().any(|h| h == "kerf.local"), "{named:?}"); + } + + /// A wildcard bind can be reached on any of this machine's addresses, so + /// there is no list to write: an empty list is rmcp's "allow any". + #[test] + fn a_wildcard_bind_disables_host_validation() { + for addr in ["0.0.0.0:7777", "[::]:7777"] { + assert!(allowed_hosts(addr).is_empty(), "{addr} should allow any host"); + } + } + + /// A malformed or port-less override must not silently drop the loopback + /// defaults and lock the user out of the default endpoint. + #[test] + fn odd_addresses_keep_loopback_reachable() { + for addr in ["", ":7777", "127.0.0.1", "not a host:port"] { + let hosts = allowed_hosts(addr); + assert!(hosts.iter().any(|h| h == "127.0.0.1"), "{addr} must keep loopback: {hosts:?}"); + } + } + /// The visual tools hand the model a caption plus a raw-base64 image block. + /// This asserts the shape that actually goes over the wire — rmcp wants + /// bare base64 under `data` with a sibling `mimeType`, never a `data:` URL, + /// and getting that wrong degrades silently into an image the model cannot + /// see rather than into a build failure. + #[test] + fn image_results_serialize_as_caption_plus_bare_base64() { + let jpeg = [0xFF, 0xD8, 0xFF, 0xD9]; + let value = + serde_json::to_value(image_result("at 00:02.000".to_string(), jpeg.to_vec())).expect("CallToolResult serializes"); + + let content = value["content"].as_array().expect("content is an array"); + assert_eq!(content.len(), 2, "expected caption + image: {content:?}"); + + assert_eq!(content[0]["type"], "text"); + assert_eq!(content[0]["text"], "at 00:02.000"); + + assert_eq!(content[1]["type"], "image"); + assert_eq!(content[1]["mimeType"], "image/jpeg"); + let data = content[1]["data"].as_str().expect("image data is a string"); + assert_eq!(data, "/9j/2Q=="); + assert!(!data.starts_with("data:"), "must be bare base64, not a data: URL"); + + assert_eq!(value["isError"], false); + } } From 0f3464fdf6b6eae463b5fe45e9eaa585470b05f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Orell=20B=C3=BChler?= Date: Sun, 23 Aug 2026 20:48:55 +0200 Subject: [PATCH 2/4] name the MCP server kerf, not rmcp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ServerInfo::default()` fills `server_info` from rmcp's own build env, so every client listed the endpoint as "rmcp" 3.1.4 — wrong on 1.7 too, and invisible from the Rust side because nothing about it fails to compile. --- CLAUDE.md | 4 +++- crates/kerf-app/src/mcp.rs | 22 ++++++++++++++++++++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 3b911bf..3d7e12f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -350,7 +350,9 @@ hold, so the agent edits the project the user has open. Patterns that matter if it: `#[tool_router]` on the impl + `#[tool_handler]` on `impl ServerHandler` — **no `tool_router` field on the struct** (the macro calls `Self::tool_router()`). `ServerInfo` is `#[non_exhaustive]`, so `get_info` builds it via `Default::default()` -then mutates fields. Most tools return `Result` (pretty JSON), but the +then mutates fields — including `server_info` (`server_identity`), because that +default is filled from **rmcp's own** crate identity and left alone the server +introduces itself to every client as "rmcp". Most tools return `Result` (pretty JSON), but the three **visual** tools — `get_frame` (a single drill-in frame), `skim_asset` (a contact-sheet montage of an asset + a text index of cell→timestamp, for finding good parts) and `preview_timeline` (the composited cut at a timeline time) — return diff --git a/crates/kerf-app/src/mcp.rs b/crates/kerf-app/src/mcp.rs index 7c3f671..c7fe201 100644 --- a/crates/kerf-app/src/mcp.rs +++ b/crates/kerf-app/src/mcp.rs @@ -19,7 +19,7 @@ use kerf_core::{ TextKeyframe, Transition, TransitionKind, VideoEffect, }; use rmcp::handler::server::wrapper::Parameters; -use rmcp::model::{CallToolResult, ContentBlock, ServerCapabilities, ServerInfo}; +use rmcp::model::{CallToolResult, ContentBlock, Implementation, ServerCapabilities, ServerInfo}; use rmcp::transport::streamable_http_server::{ session::local::LocalSessionManager, StreamableHttpServerConfig, StreamableHttpService, }; @@ -1654,10 +1654,18 @@ async fn blocking(job: impl FnOnce() -> Result + .map_err(|e| McpError::internal_error(e.to_string(), None))? } +/// How the server introduces itself to clients. `ServerInfo::default()` fills +/// `server_info` in from *rmcp's* own build env, so an untouched default has +/// every client listing this server as "rmcp". +fn server_identity() -> Implementation { + Implementation::new("kerf", env!("CARGO_PKG_VERSION")) +} + #[tool_handler] impl ServerHandler for KerfMcp { fn get_info(&self) -> ServerInfo { let mut info = ServerInfo::default(); + info.server_info = server_identity(); info.capabilities = ServerCapabilities::builder().enable_tools().build(); info.instructions = Some( "Kerf MCP server. The user queues editing tasks in the desktop app; \ @@ -1850,7 +1858,7 @@ fn json(value: &T) -> Result { #[cfg(test)] mod tests { - use super::{allowed_hosts, fmt_ts, image_result, KerfMcp}; + use super::{allowed_hosts, fmt_ts, image_result, server_identity, KerfMcp}; #[test] fn fmt_ts_carries_at_minute_boundaries() { @@ -1966,6 +1974,16 @@ mod tests { assert!(hosts.iter().any(|h| h == "127.0.0.1"), "{addr} must keep loopback: {hosts:?}"); } } + /// Clients list a server by its `serverInfo`, and rmcp's default fills that + /// in from its own crate identity — so the app has to name itself. + #[test] + fn the_server_introduces_itself_as_kerf() { + let info = server_identity(); + assert_eq!(info.name, "kerf"); + assert_eq!(info.version, env!("CARGO_PKG_VERSION")); + assert_ne!(info.name, "rmcp", "server_info must not be rmcp's own identity"); + } + /// The visual tools hand the model a caption plus a raw-base64 image block. /// This asserts the shape that actually goes over the wire — rmcp wants /// bare base64 under `data` with a sibling `mimeType`, never a `data:` URL, From c2058d36e670903af790a37fb41dd12174257bdc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Orell=20B=C3=BChler?= Date: Sun, 23 Aug 2026 21:19:52 +0200 Subject: [PATCH 3/4] embed the Windows app manifest through the linker, so tests get it too Tauri's manifest rides in the .res, which cargo links into bins only. The lib test binary thus ran with no activation context, bound comctl32 v5, and died with STATUS_ENTRYPOINT_NOT_FOUND on rfd's TaskDialogIndirect import before any test ran. --- CLAUDE.md | 8 ++++++++ crates/kerf-app/build.rs | 20 +++++++++++++++++++- crates/kerf-app/windows-app-manifest.xml | 14 ++++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 crates/kerf-app/windows-app-manifest.xml diff --git a/CLAUDE.md b/CLAUDE.md index 3d7e12f..4405d3e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -422,6 +422,14 @@ snake_case (`{ assetId }` → `asset_id`). Config: `tauri.conf.json` points which for this `crates/kerf-app` layout resolves to `crates/`, not the config dir or repo root — so they anchor to the repo via `cd "$(git rev-parse --show-toplevel)/frontend" && bun run dev` instead of a fragile relative path. +`build.rs` takes the **Windows app manifest** away from Tauri +(`new_without_app_manifest`) and embeds `windows-app-manifest.xml` through the +linker instead: Tauri's copy rides in the `.res`, which cargo links into *bins* +only, so the lib's test binary ran with no activation context, bound comctl32 +**v5**, and died with `STATUS_ENTRYPOINT_NOT_FOUND` on the `TaskDialogIndirect` +import rfd (via `tauri-plugin-dialog`) contributes — before a single test ran. +Whether the linker pulls that object in at all shifts with unrelated dependency +bumps, which is how an rmcp upgrade broke `cargo test -p kerf-app` on Windows. `capabilities/default.json` grants `core:default` + `dialog:default` + `updater:default` + `process:allow-restart` + `opener:allow-open-url`. That last one enables the command **with no scope of its own** (`allow-default-urls` is a diff --git a/crates/kerf-app/build.rs b/crates/kerf-app/build.rs index 261851f..21aa4c8 100644 --- a/crates/kerf-app/build.rs +++ b/crates/kerf-app/build.rs @@ -1,3 +1,21 @@ fn main() { - tauri_build::build(); + // Tauri's default app manifest — the one asking for Common-Controls v6 — + // rides in the Windows resource file, which cargo links into **bins only**. + // The lib's own test binary therefore starts with no activation context, so + // the loader binds comctl32 v5; and rfd (via tauri-plugin-dialog) statically + // imports `TaskDialogIndirect`, which only v6 exports. The test exe then dies + // with STATUS_ENTRYPOINT_NOT_FOUND before a single test runs. Whether the + // linker pulls that object in at all shifts with unrelated dependency + // changes, so pass the same manifest through the linker instead: that covers + // every binary this crate links, tests included. + let attributes = + tauri_build::Attributes::new().windows_attributes(tauri_build::WindowsAttributes::new_without_app_manifest()); + tauri_build::try_build(attributes).expect("tauri-build failed"); + + if std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc") { + let manifest = std::path::Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap()).join("windows-app-manifest.xml"); + println!("cargo:rerun-if-changed=windows-app-manifest.xml"); + println!("cargo:rustc-link-arg=/MANIFEST:EMBED"); + println!("cargo:rustc-link-arg=/MANIFESTINPUT:{}", manifest.display()); + } } diff --git a/crates/kerf-app/windows-app-manifest.xml b/crates/kerf-app/windows-app-manifest.xml new file mode 100644 index 0000000..2d510ed --- /dev/null +++ b/crates/kerf-app/windows-app-manifest.xml @@ -0,0 +1,14 @@ + + + + + + + From 01e259854a943d236e254e16c1a8d68bfd68452c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Orell=20B=C3=BChler?= Date: Sun, 23 Aug 2026 21:26:47 +0200 Subject: [PATCH 4/4] release 0.19.2 --- Cargo.lock | 4 ++-- Cargo.toml | 2 +- crates/kerf-app/tauri.conf.json | 2 +- frontend/package.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0a65af3..915cfa8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2193,7 +2193,7 @@ dependencies = [ [[package]] name = "kerf-app" -version = "0.19.1" +version = "0.19.2" dependencies = [ "anyhow", "axum", @@ -2218,7 +2218,7 @@ dependencies = [ [[package]] name = "kerf-core" -version = "0.19.1" +version = "0.19.2" dependencies = [ "chrono", "dirs", diff --git a/Cargo.toml b/Cargo.toml index 40211e2..971de19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "2" members = ["crates/kerf-core", "crates/kerf-app"] [workspace.package] -version = "0.19.1" +version = "0.19.2" edition = "2021" rust-version = "1.95" license = "PolyForm-Noncommercial-1.0.0" diff --git a/crates/kerf-app/tauri.conf.json b/crates/kerf-app/tauri.conf.json index 21eef89..34f1e70 100644 --- a/crates/kerf-app/tauri.conf.json +++ b/crates/kerf-app/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Kerf", - "version": "0.19.1", + "version": "0.19.2", "identifier": "ch.orellbuehler.kerf", "build": { "frontendDist": "../../frontend/build", diff --git a/frontend/package.json b/frontend/package.json index 8bdc4af..59a221e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "frontend", "private": true, - "version": "0.19.1", + "version": "0.19.2", "type": "module", "scripts": { "dev": "vite dev",