From f4e90a13d0ca34af69e8feed4707d988706d6ec8 Mon Sep 17 00:00:00 2001 From: lick <2188718831@qq.com> Date: Wed, 28 Oct 2026 01:42:52 -0700 Subject: [PATCH 1/2] fix(mcp): stabilize Windows request delivery and protocol discovery --- ...26-09-30-ide-capabilities-and-local-mcp.md | 31 +- .github/workflows/ci-windows.yml | 10 + rust/lithe-core/src/lib.rs | 11 + rust/lithe-ide-host/src/bin/lithe-mcp.rs | 8 +- rust/lithe-ide-host/src/lib.rs | 364 ++++++++++++++++-- scripts/test-ide-mcp.mjs | 34 +- shared/contracts/ide-api/v1.md | 32 +- shared/contracts/rust-core-api.md | 5 + shared/fixtures/ide-api/mcp-protocol.json | 8 + shared/i18n/locales/en-US.json | 3 + shared/i18n/locales/zh-CN.json | 3 + shared/i18n/messages.json | 21 + .../features/ide-mcp-connection.json | 11 +- windows/tauri/src-tauri/src/platform.rs | 150 +++++++- .../host-api/ide-capabilities.test.ts | 14 + .../src/features/host-api/ide-capabilities.ts | 10 +- .../src/features/host-api/mcp-connection.ts | 15 +- .../features/host-api/mcp-lifecycle.test.ts | 252 ++++++++---- .../src/features/host-api/mcp-lifecycle.ts | 131 ++++--- .../src/features/host-api/mcp-settings.tsx | 9 +- windows/tauri/src/i18n/shared.generated.ts | 6 + windows/tauri/src/platform/tauri-core.test.ts | 10 + windows/tauri/src/platform/tauri-core.ts | 6 +- 23 files changed, 947 insertions(+), 197 deletions(-) create mode 100644 shared/fixtures/ide-api/mcp-protocol.json diff --git a/.agents/notes/implemented/architecture/2026-09-30-ide-capabilities-and-local-mcp.md b/.agents/notes/implemented/architecture/2026-09-30-ide-capabilities-and-local-mcp.md index ab4f9c1f43..a2dc366829 100644 --- a/.agents/notes/implemented/architecture/2026-09-30-ide-capabilities-and-local-mcp.md +++ b/.agents/notes/implemented/architecture/2026-09-30-ide-capabilities-and-local-mcp.md @@ -32,8 +32,8 @@ Lithe 对外开放项目环境、Maven、运行配置和执行输出,复用界 JDK/Maven 环境设置内;未打开项目时显示提示,不创建授权。读取、配置和执行权限按项目处理。配置与执行 默认关闭。项目关闭、窗口销毁和应用退出都关闭连接。禁用 MCP 后,已接受的 执行仍归 IDE 管理,不能因为一个客户端断线误杀用户正在看的服务。 - Windows 从打开连接前就登记授权代际;撤销后迟到的原生连接必须关闭,旧轮询 - 只能清理自身连接。项目关闭会等待正在打开的连接完成清理,不能只遍历已显示的配置。 + Windows 从打开连接前就登记授权代际;撤销后迟到的原生连接必须关闭,旧通知 + 只能影响自身连接。项目关闭会等待正在打开的连接完成清理,不能只遍历已显示的配置。 Windows 原生层为 `open` 注入当前窗口的 `ownerId`,覆盖调用方提供的所有者; 该请求由严格 camelCase 结构解码。`closeOwner` 沿用独立的 `ownerID` 字段, 不可对所有动作全局替换字段名,否则窗口销毁时无法撤销访问。 @@ -42,6 +42,18 @@ Lithe 对外开放项目环境、Maven、运行配置和执行输出,复用界 普通应用的主输出面板与服务标签均持有执行 ID;Windows 取消 Java 启动确认时 同步清除准备中状态,关闭项目时先撤销 API,再清理已有运行资源。 +- Windows 请求调度由原生 broker 接收后通过 Tauri 通道推送,通道由连接持有, + 关闭时释放;不再依赖 WebView 的 200ms 定时器,也不拿前端后台暂停当作授权失效。 + 通知只包含请求身份,应用恢复后先通过 `claim` 领取未过期、未取消且未领取的请求。 + 领取响应携带剩余有效期;前端从发起领取前计时,执行前拒绝超时或时钟倒退的响应, + 避免领取结果本身滞留在暂停的 WebView 中。真正执行仍走原工作区能力接口,不能把业务状态复制到 Rust 通信层。 +- 一次通道或回应失败不撤销授权;下一次新请求可以自然恢复,但不自动重放任何 + 已领取操作。超时后用户需要先检查操作状态。设置页展示授权和最近请求结果, + 不把外部客户端能列出工具等同于工作区可执行。 +- MCP 列表明确提供不缓存、私有的缓存策略;SDK 声明支持新协议并不代表应用 + 用默认值构造的结果满足新协议。旧版、新版直接发现及兼容初始化分别使用独立 + helper 进程验证,不能只在旧版会话中追加一个新版本请求。 + 正确示例:MCP 的 Maven execute 调用现有 Maven 功能的 runGoals/runCustomGoal, 沿用项目的工具链与 Profile;不要在辅助程序里自行执行 `mvn` 并读取另一套配置。 @@ -60,6 +72,11 @@ Lithe 对外开放项目环境、Maven、运行配置和执行输出,复用界 4. **将 MCP 协议写两遍**:增加版本兼容和测试成本。使用官方 SDK 并保留平台 应用动作接线,避免为了共享而迁移整个原生工作台。 +5. **只延长心跳或无限重试**:只能掩盖 WebView 调度暂停,不能阻止恢复后执行旧操作。 + 原生通知加执行前领取去掉心跳依赖,同时保留请求上限、超时和显式撤销。 +6. **把 Git/Agent 进度回调用于连接通知**:前者随单次命令结束,后者随授权连接结束, + 生命周期不同,因此采用单独的原生适配器入口,不改变既有进度回调契约。 + ## 后果 用户复制一次客户端配置后,可以通过 AI 使用 IDE 的环境、Maven 和运行操作。 @@ -76,14 +93,18 @@ Rust 命令可以直接替代当前窗口或工作区的状态所有权。 - `cargo test --manifest-path rust/Cargo.toml -p lithe-ide-host` 验证授权、路径、 模块参数、输出游标、连接互斥、请求路由和打包输入不变。 - `node --test scripts/test-ide-mcp.mjs` 在先构建辅助程序后验证真实 stdio - 握手、工具发现、结构化结果和可见工具错误。 + 握手、工具发现、结构化结果和可见工具错误。Windows CI 在共享 Rust 测试后 + 显式构建 helper 并执行同一协议矩阵,不能仅测试依赖库而遗漏发行平台的 stdio 程序。 - `./scripts/verify-shared-contracts.sh`、`./scripts/verify-windows-boundaries.sh`、 `./scripts/verify-runtime-bundle-immutability.sh` 验证契约和平台边界。 - Windows 的 `ide-capabilities.test.ts` 验证旧执行 ID、撤销期间的设置写入、 设置补丁和共享权限拒绝;macOS 的 `IdeCapabilitiesTests.swift` 验证授权与 工作区代际、模块不被错误激活以及执行身份。 -- `mcp-lifecycle.test.ts` 用可控原生响应和手动调度器验证打开期间关闭项目、 - 全局退出和旧轮询失败不会恢复授权或撤销新连接;测试完成后必须清理所有计时器。 +- `mcp-lifecycle.test.ts` 用可控通知和领取响应验证打开期间关闭项目、全局退出、 + 迟到/重复事件、单次失败后新请求恢复与未知回应不重放;不用真实睡眠模拟休眠。 +- 共享 broker 测试把心跳和请求时间设为已过期,验证原生推送不依赖心跳、过期或 + 已取消请求不能领取、通道错误不撤销授权,以及关闭后等待者与通道被释放。 + Windows 原生测试通过真实 loopback HTTP、Core 和 Tauri Channel 完整验证通知与响应。 - Worker 宿主测试验证未声明权限时不能发现或调用项目、显式工作区不被当前焦点 替换以及应用层拒绝原样返回。Maven 的 API 保存等待真实写入结果;失败进程的 输出读取不能被 Windows Git 兼容层误判为 IPC 失败。 diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index d156e07f65..69457ffff1 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -719,6 +719,16 @@ jobs: timeout-minutes: 20 run: ./.agents/skills/write-stable-tests/scripts/test-stability-windows.ps1 -Scope SharedRust -SuiteTimeoutSeconds 1080 + - name: Verify Windows MCP stdio protocol + if: needs.changes.outputs.rust_core == 'true' + shell: pwsh + timeout-minutes: 5 + run: | + cargo build --locked --manifest-path rust/Cargo.toml -p lithe-ide-host --features mcp --bin lithe-mcp + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + node --test --test-reporter=spec --test-reporter-destination=stdout --test-reporter=junit --test-reporter-destination=.artifacts/test-stability/mcp-protocol.junit.xml scripts/test-ide-mcp.mjs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + - name: Test Windows Rust host if: needs.changes.outputs.windows_rust == 'true' shell: pwsh diff --git a/rust/lithe-core/src/lib.rs b/rust/lithe-core/src/lib.rs index f3100cf1e2..45d4696b24 100644 --- a/rust/lithe-core/src/lib.rs +++ b/rust/lithe-core/src/lib.rs @@ -74,3 +74,14 @@ pub fn git_askpass_main(prompt: &str) -> i32 { /// Shared classification for ordinary project watcher events after root resolution. pub use project::is_workspace_event_relevant; + +/// Connects an authorized native window to the local IDE broker's request notifications. +/// The host retains the callback until close; receivers claim IDs through `ideHost.control` +/// before invoking application actions. This adapter boundary has no UI dependencies. +pub fn attach_ide_request_dispatcher( + host_id: &str, + owner: &str, + dispatcher: std::sync::Arc Result<(), String> + Send + Sync>, +) -> Result<(), String> { + lithe_ide_host::attach_dispatcher(host_id, owner, dispatcher) +} diff --git a/rust/lithe-ide-host/src/bin/lithe-mcp.rs b/rust/lithe-ide-host/src/bin/lithe-mcp.rs index d53527f43e..54017bd5c1 100644 --- a/rust/lithe-ide-host/src/bin/lithe-mcp.rs +++ b/rust/lithe-ide-host/src/bin/lithe-mcp.rs @@ -25,10 +25,10 @@ impl ServerHandler for Ide { .map(serde_json::from_value) .collect::, _>>() .map_err(|_| ErrorData::internal_error("Invalid IDE tool catalog", None))?; - Ok(ListToolsResult { - tools, - ..Default::default() - }) + // New protocol revisions require explicit cache policy on list results. + Ok(ListToolsResult::with_all_items(tools) + .with_ttl_ms(0) + .with_cache_scope(CacheScope::Private)) } async fn call_tool( &self, diff --git a/rust/lithe-ide-host/src/lib.rs b/rust/lithe-ide-host/src/lib.rs index 3a389e152e..703d9ff773 100644 --- a/rust/lithe-ide-host/src/lib.rs +++ b/rust/lithe-ide-host/src/lib.rs @@ -44,12 +44,33 @@ struct Pending { claimed: bool, created: Instant, } +/// Native push notification. The receiver must claim the request before executing it. +/// Returning an error fails only that delivery; it never replays an operation. +pub type RequestDispatcher = Arc Result<(), String> + Send + Sync>; + +fn prune_pending(pending: &mut BTreeMap) { + let expired: Vec<_> = pending + .iter() + .filter(|(_, p)| p.reply.is_closed() || p.created.elapsed() >= REQUEST_TIMEOUT) + .map(|(id, _)| id.clone()) + .collect(); + for id in expired { + if let Some(p) = pending.remove(&id) { + let _ = p.reply.send(failure( + "TIMEOUT", + "The IDE request expired. Inspect operations before retrying a mutation.", + )); + } + } +} + struct Broker { enabled: AtomicBool, token: String, permissions: Permissions, pending: Mutex>, last_poll: Mutex, + dispatcher: Mutex>, queued: tokio::sync::Notify, } struct Host { @@ -175,6 +196,7 @@ fn open(args: Value) -> Result { permissions: args.permissions, pending: Mutex::new(BTreeMap::new()), last_poll: Mutex::new(Instant::now()), + dispatcher: Mutex::new(None), queued: tokio::sync::Notify::new(), }); let runtime = runtime()?; @@ -208,6 +230,66 @@ fn open(args: Value) -> Result { Ok(json!({"hostID":id,"configuration":configuration})) } +/// Attaches a native window's request channel without changing its authorization. +/// Notifications contain only an ID: delayed events cannot execute expired work. +pub fn attach_dispatcher( + host_id: &str, + owner: &str, + dispatcher: RequestDispatcher, +) -> Result<(), String> { + let broker = { + let hosts = hosts().lock().map_err(|_| "IDE host unavailable")?; + let host = hosts + .get(host_id) + .ok_or("IDE access is no longer enabled")?; + if host.owner != owner { + return Err("IDE connection belongs to another window".into()); + } + host.broker.clone() + }; + let queued = { + let mut slot = broker + .dispatcher + .lock() + .map_err(|_| "IDE dispatcher unavailable")?; + if slot.is_some() { + return Err("IDE dispatcher is already attached".into()); + } + let pending = broker.pending.lock().map_err(|_| "IDE queue unavailable")?; + if !broker.enabled.load(Ordering::Acquire) { + return Err("IDE access is no longer enabled".into()); + } + *slot = Some(dispatcher.clone()); + pending + .iter() + .filter(|(_, p)| { + !p.claimed && !p.reply.is_closed() && p.created.elapsed() < REQUEST_TIMEOUT + }) + .map(|(id, _)| id.clone()) + .collect::>() + }; + for id in queued { + deliver(&broker, &dispatcher, id); + } + Ok(()) +} + +fn deliver(broker: &Broker, dispatcher: &RequestDispatcher, id: String) { + // Never call platform code under queue/host locks; a receiver may respond inline. + if dispatcher(id.clone()).is_err() { + if let Ok(mut pending) = broker.pending.lock() { + if pending.get(&id).is_some_and(|p| !p.claimed) { + if let Some(request) = pending.remove(&id) { + let _ = request.reply.send(failure( + "DISPATCHER_UNAVAILABLE", + "The project could not receive this request. Retry after the IDE responds.", + )); + } + } + } + } +} + // Constant-time comparison avoids making the bearer check depend on a matching prefix. fn authorized(headers: &HeaderMap, token: &str) -> bool { if headers.contains_key("origin") { @@ -256,43 +338,56 @@ async fn call( if let Err(error) = catalog::validate(&request.name, &request.arguments, &broker.permissions) { return response(error); } - if broker - .last_poll - .lock() - .map(|t| t.elapsed() > Duration::from_secs(15)) - .unwrap_or(true) - { - return response(failure( - "DISCONNECTED", - "IDE project dispatcher is not responding", - )); - } - let (sender, receiver) = oneshot::channel(); - let id = Uuid::new_v4().to_string(); - { - let Ok(mut pending) = broker.pending.lock() else { - return response(failure("UNAVAILABLE", "IDE request queue unavailable")); + let (dispatcher, receiver, id) = { + // Event-driven native hosts do not depend on WebView timer heartbeats. + // Hold registration through enqueue so attaching cannot miss a pending request. + let dispatcher = match broker.dispatcher.lock() { + Ok(dispatcher) => dispatcher, + Err(_) => return response(failure("UNAVAILABLE", "IDE dispatcher unavailable")), }; - // An already established HTTP connection can outlive the listener. - // Check under the queue lock so revocation cannot miss a late insertion. - if !broker.enabled.load(Ordering::Acquire) { - return response(failure("DISCONNECTED", "IDE access was disabled")); + if dispatcher.is_none() + && broker + .last_poll + .lock() + .map(|t| t.elapsed() > Duration::from_secs(15)) + .unwrap_or(true) + { + return response(failure( + "DISCONNECTED", + "IDE project dispatcher is not responding", + )); } - pending.retain(|_, p| !p.reply.is_closed() && p.created.elapsed() < REQUEST_TIMEOUT); - if pending.len() >= MAX_PENDING { - return response(failure("BUSY", "Too many pending IDE operations")); + let (sender, receiver) = oneshot::channel(); + let id = Uuid::new_v4().to_string(); + { + let Ok(mut pending) = broker.pending.lock() else { + return response(failure("UNAVAILABLE", "IDE request queue unavailable")); + }; + // An already established HTTP connection can outlive the listener. + // Check under the queue lock so revocation cannot miss a late insertion. + if !broker.enabled.load(Ordering::Acquire) { + return response(failure("DISCONNECTED", "IDE access was disabled")); + } + prune_pending(&mut pending); + if pending.len() >= MAX_PENDING { + return response(failure("BUSY", "Too many pending IDE operations")); + } + pending.insert( + id.clone(), + Pending { + call: request, + reply: sender, + claimed: false, + created: Instant::now(), + }, + ); } - pending.insert( - id.clone(), - Pending { - call: request, - reply: sender, - claimed: false, - created: Instant::now(), - }, - ); - } + (dispatcher.clone(), receiver, id) + }; broker.queued.notify_one(); + if let Some(dispatcher) = dispatcher { + deliver(&broker, &dispatcher, id.clone()); + } let _lease = PendingLease { broker, id }; let result = match tokio::time::timeout(REQUEST_TIMEOUT, receiver).await { Ok(Ok(value)) => value, @@ -361,13 +456,23 @@ pub fn execute(action: &str, args: Value) -> Result { .lock() .map_err(|_| "IDE queue unavailable")?; match action { + "claim" => { + let request_id = args["requestID"].as_str().ok_or("Missing requestID")?; + prune_pending(&mut pending); + let request = pending.get_mut(request_id).filter(|p| !p.claimed).map(|p| { + p.claimed = true; + json!({"requestID":request_id,"name":p.call.name,"arguments":p.call.arguments, + "remainingMilliseconds":REQUEST_TIMEOUT.saturating_sub(p.created.elapsed()).as_millis() as u64}) + }); + Ok(json!({"request":request})) + } "poll" => { *host .broker .last_poll .lock() .map_err(|_| "IDE host unavailable")? = Instant::now(); - pending.retain(|_, p| !p.reply.is_closed() && p.created.elapsed() < REQUEST_TIMEOUT); + prune_pending(&mut pending); let requests: Vec<_> = pending .iter_mut() .filter(|(_, p)| !p.claimed) @@ -499,6 +604,197 @@ mod tests { 1 ); } + fn push_fixture() -> (SessionFixture, Arc, HeaderMap) { + let root = std::env::temp_dir().join(format!("lithe-ide-push-{}", Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + let helper = root.join("helper"); + std::fs::write(&helper, b"read-only").unwrap(); + let mut fixture = SessionFixture { + root: root.clone(), + id: String::new(), + }; + let opened = open( + json!({"directory":root.join("data"),"workspaceKey":"fixture", "helperPath":helper, + "permissions":{"configure":true,"execute":true},"ownerId":"fixture-owner"}), + ) + .unwrap(); + fixture.id = opened["hostID"].as_str().unwrap().into(); + let broker = hosts().lock().unwrap()[&fixture.id].broker.clone(); + let mut headers = HeaderMap::new(); + headers.insert( + "authorization", + format!("Bearer {}", broker.token).parse().unwrap(), + ); + (fixture, broker, headers) + } + + async fn notification(rx: &mut tokio::sync::mpsc::UnboundedReceiver) -> Option { + // test-stability: allow(rust-unbounded-receive) reason: Tokio's async receiver has no recv_timeout; the enclosing two-second timeout bounds this await without blocking the executor. + tokio::time::timeout(Duration::from_secs(2), rx.recv()) + .await + .expect("native request notification deadline") + } + + fn inspection() -> Json { + Json(Call { + name: "lithe_project_inspect".into(), + arguments: json!({}), + }) + } + + #[tokio::test] + async fn push_dispatch_recovers_after_idle_without_heartbeat_and_claims_only_once() { + let (fixture, broker, headers) = push_fixture(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + let dispatcher: RequestDispatcher = + Arc::new(move |id| tx.send(id).map_err(|e| e.to_string())); + assert!(attach_dispatcher(&fixture.id, "other-owner", dispatcher.clone()).is_err()); + attach_dispatcher(&fixture.id, "fixture-owner", dispatcher.clone()).unwrap(); + assert!(attach_dispatcher(&fixture.id, "fixture-owner", dispatcher).is_err()); + *broker.last_poll.lock().unwrap() = Instant::now() - Duration::from_secs(3600); + let application = async { + let id = notification(&mut rx).await.unwrap(); + let args = json!({"hostID":fixture.id,"requestID":id}); + let claimed = execute("claim", args.clone()).unwrap(); + assert_eq!(claimed["request"]["name"], "lithe_project_inspect"); + assert!(execute("claim", args).unwrap()["request"].is_null()); + execute( + "respond", + json!({"hostID":fixture.id,"requestID":id,"result":{"ok":true}}), + ) + .unwrap(); + }; + let (response, ()) = tokio::join!(call(State(broker), headers, inspection()), application); + assert_eq!(response.1 .0["ok"], true); + } + + #[tokio::test] + async fn attaching_after_enqueue_delivers_the_existing_request_once() { + let (fixture, broker, headers) = push_fixture(); + let application = async { + tokio::time::timeout(Duration::from_secs(2), broker.queued.notified()) + .await + .unwrap(); + let host = fixture.id.clone(); + attach_dispatcher( + &fixture.id, + "fixture-owner", + Arc::new(move |id| { + assert!( + execute("claim", json!({"hostID":host,"requestID":id}))?["request"] + .is_object() + ); + execute( + "respond", + json!({"hostID":host,"requestID":id,"result":{"ok":true}}), + )?; + Ok(()) + }), + ) + .unwrap(); + }; + let (response, ()) = tokio::join!( + call(State(broker.clone()), headers, inspection()), + application + ); + assert_eq!(response.1 .0["ok"], true); + assert!(broker.pending.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn delayed_push_cannot_claim_expired_or_cancelled_work() { + let (fixture, broker, headers) = push_fixture(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + attach_dispatcher( + &fixture.id, + "fixture-owner", + Arc::new(move |id| tx.send(id).map_err(|e| e.to_string())), + ) + .unwrap(); + let application = async { + let id = notification(&mut rx).await.unwrap(); + // Simulate a suspended UI deterministically, without waiting for the deadline. + broker.pending.lock().unwrap().get_mut(&id).unwrap().created = + Instant::now() - REQUEST_TIMEOUT; + assert!( + execute("claim", json!({"hostID":fixture.id,"requestID":id})).unwrap()["request"] + .is_null() + ); + }; + let (response, ()) = tokio::join!( + call(State(broker.clone()), headers.clone(), inspection()), + application + ); + assert_eq!(response.1 .0["error"]["code"], "TIMEOUT"); + let id = { + let call = call(State(broker.clone()), headers, inspection()); + tokio::pin!(call); + tokio::select! { + _ = &mut call => panic!("unanswered call completed"), + id = notification(&mut rx) => id.unwrap(), + } + // Dropping the HTTP handler cancels ownership of the queued request. + }; + assert!( + execute("claim", json!({"hostID":fixture.id,"requestID":id})).unwrap()["request"] + .is_null() + ); + assert!(broker.pending.lock().unwrap().is_empty()); + } + + #[tokio::test] + async fn failed_push_does_not_revoke_authorization_or_replay_a_request() { + let (fixture, broker, headers) = push_fixture(); + let failing = Arc::new(AtomicBool::new(true)); + let switch = failing.clone(); + let host = fixture.id.clone(); + attach_dispatcher( + &fixture.id, + "fixture-owner", + Arc::new(move |id| { + if switch.load(Ordering::Acquire) { + return Err("receiver unavailable".into()); + } + // Inline delivery also proves no broker lock is held while calling the adapter. + let request = execute("claim", json!({"hostID":host,"requestID":id}))?; + assert!(request["request"].is_object()); + execute( + "respond", + json!({"hostID":host,"requestID":id,"result":{"ok":true}}), + )?; + Ok(()) + }), + ) + .unwrap(); + let response = call(State(broker.clone()), headers.clone(), inspection()).await; + assert_eq!(response.1 .0["error"]["code"], "DISPATCHER_UNAVAILABLE"); + assert!(broker.pending.lock().unwrap().is_empty()); + assert!(broker.enabled.load(Ordering::Acquire)); + failing.store(false, Ordering::Release); + let response = call(State(broker), headers, inspection()).await; + assert_eq!(response.1 .0["ok"], true); + } + + #[tokio::test] + async fn closing_owner_rejects_a_queued_push_and_releases_the_channel() { + let (fixture, broker, headers) = push_fixture(); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + attach_dispatcher( + &fixture.id, + "fixture-owner", + Arc::new(move |id| tx.send(id).map_err(|e| e.to_string())), + ) + .unwrap(); + let application = async { + let id = notification(&mut rx).await.unwrap(); + execute("close", json!({"hostID":fixture.id})).unwrap(); + assert!(execute("claim", json!({"hostID":fixture.id,"requestID":id})).is_err()); + }; + let (response, ()) = tokio::join!(call(State(broker), headers, inspection()), application); + assert_eq!(response.1 .0["error"]["code"], "DISCONNECTED"); + assert!(notification(&mut rx).await.is_none()); + } + #[test] fn browser_origin_and_wrong_token_are_rejected() { let mut h = HeaderMap::new(); diff --git a/scripts/test-ide-mcp.mjs b/scripts/test-ide-mcp.mjs index 78cb3ef81a..92c46bea6a 100644 --- a/scripts/test-ide-mcp.mjs +++ b/scripts/test-ide-mcp.mjs @@ -3,7 +3,7 @@ import test from "node:test"; import { spawn } from "node:child_process"; import { createServer } from "node:http"; import { createInterface } from "node:readline"; -import { mkdtemp, writeFile, rm } from "node:fs/promises"; +import { mkdtemp, writeFile, readFile, rm } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { fileURLToPath } from "node:url"; @@ -11,7 +11,9 @@ import { fileURLToPath } from "node:url"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const executable = process.env.LITHE_MCP_TEST_EXECUTABLE || path.join(root,"rust/target/debug", process.platform === "win32" ? "lithe-mcp.exe" : "lithe-mcp"); -test("stdio MCP negotiates, discovers IDE tools, routes calls and reports tool failures", {timeout:15000}, async t => { +const fixture = JSON.parse(await readFile(path.join(root,"shared/fixtures/ide-api/mcp-protocol.json"),"utf8")); +for (const mode of fixture.modes) { +test(`stdio MCP ${mode}: discovery, calls and tool failures`, {timeout:15000}, async t => { const directory = await mkdtemp(path.join(os.tmpdir(),"lithe-mcp-protocol-")); t.after(()=>rm(directory,{recursive:true,force:true})); const requests = []; @@ -58,18 +60,34 @@ test("stdio MCP negotiates, discovers IDE tools, routes calls and reports tool f pending.set(id,{resolve:finish(resolve),reject:finish(reject)}); child.stdin.write(JSON.stringify({jsonrpc:"2.0",id,method,params})+"\n"); }); - const initialized = await send("initialize",{protocolVersion:"2025-11-25",capabilities:{},clientInfo:{name:"lithe-fixture",version:"1"}}); - assert.equal(initialized.result.serverInfo.name,"lithe"); - child.stdin.write(JSON.stringify({jsonrpc:"2.0",method:"notifications/initialized"})+"\n"); - const listed = await send("tools/list",{}); + // Each protocol mode owns a fresh process: legacy initialization must not + // hide failures in clients that start directly with modern discovery. + if (mode !== "modern") { + const initialized = await send("initialize",{protocolVersion:mode === "legacy" ? fixture.legacyProtocolVersion : fixture.modernProtocolVersion,capabilities:{},clientInfo:{name:"lithe-fixture",version:"1"}}); + assert.equal(initialized.result.serverInfo.name,"lithe"); + child.stdin.write(JSON.stringify({jsonrpc:"2.0",method:"notifications/initialized"})+"\n"); + } + const params = mode === "legacy" ? {} : {_meta:{ + "io.modelcontextprotocol/protocolVersion":fixture.modernProtocolVersion, + "io.modelcontextprotocol/clientCapabilities":{}, + }}; + if (mode !== "legacy") { + const discovery = await send("server/discover",params); + assert.ok(discovery.result.supportedVersions.includes(fixture.modernProtocolVersion)); + } + const listed = await send("tools/list",params); + assert.equal(listed.result.ttlMs,fixture.cachePolicy.ttlMs); + assert.equal(listed.result.cacheScope,fixture.cachePolicy.cacheScope); + assert.equal(listed.result.tools.length,fixture.toolCount); assert.ok(listed.result.tools.some(tool=>tool.name==="lithe_environment_configure")); assert.ok(listed.result.tools.some(tool=>tool.name==="lithe_operation_output")); assert.ok(!listed.result.tools.some(tool=>/write_file|shell/.test(tool.name))); - const inspected = await send("tools/call",{name:"lithe_project_inspect",arguments:{}}); + const inspected = await send("tools/call",{...params,name:"lithe_project_inspect",arguments:{}}); assert.equal(inspected.result.structuredContent.workspaceID,"fixture-project"); assert.notEqual(inspected.result.isError,true,"a nullable domain error field is a successful inspection"); - const denied = await send("tools/call",{name:"lithe_maven_execute",arguments:{goals:["verify"]}}); + const denied = await send("tools/call",{...params,name:"lithe_maven_execute",arguments:{goals:["verify"]}}); assert.equal(denied.result.isError,true); assert.equal(denied.result.structuredContent.error.code,"PERMISSION_DENIED"); assert.equal(requests.length,2); }); +} diff --git a/shared/contracts/ide-api/v1.md b/shared/contracts/ide-api/v1.md index 6ece1768cf..151d21889c 100644 --- a/shared/contracts/ide-api/v1.md +++ b/shared/contracts/ide-api/v1.md @@ -35,13 +35,37 @@ tokens, bounds request bodies to 64 KiB and pending requests to 16, and times ou delivery after 120 seconds. Inspect operations before retrying a timed-out mutation. Native-only `ideHost.control` is not exported as an MCP tool. Its actions are -`open`, `close`, `closeOwner`, `poll`, `respond`, `catalog`, `validate` and `output`. +`open`, `close`, `closeOwner`, `poll`, `claim`, `respond`, `catalog`, `validate` and `output`. The native window binds `open.arguments.ownerId` (strict camelCase decoding); `closeOwner.arguments.ownerID` retains its existing spelling. Callers must not rename both actions together. The owner is supplied by the native adapter, not trusted from a client request. -The application pump runs only while enabled and remains live during async product -actions. A dispatcher with no heartbeat for 15 seconds rejects external work. +Windows attaches an owner-checked native request channel before returning `open`. +The broker notifies that channel with `{hostID, requestID}` when work arrives; +WebView timers and visibility do not maintain its authorization. The receiver +calls `claim` with those IDs immediately before the application action. It returns +`{request: {requestID, name, arguments, remainingMilliseconds}}` at most once, or `{request: null}` for +expired, cancelled or already claimed work. A delayed event after suspend cannot +execute an expired mutation. The Windows receiver also measures elapsed time from +before `claim` IPC and rejects an expired or clock-reversed reply before executing, +because the claim response itself may be delayed while the WebView is suspended. +`respond` reports `{delivered: boolean}`; a false +result is not permission to replay an operation. Notifications or replies that +fail never reopen a revoked host or replay work; later requests can use the same +authorization. Native window destruction still revokes the connection and releases +the retained channel. Application actions retain their existing workspace checks. + +The macOS polling adapter remains compatible: it polls only while enabled and +rejects external work after 15 seconds without a heartbeat. That heartbeat check +applies only to polling hosts, not native event subscribers. Both delivery modes +retain the 16-request bound and 120-second deadline. UI reports authorization and +the last request outcome separately; successful tool discovery is not proof that +the project dispatcher answered a tool call. + +MCP `tools/list` includes `ttlMs: 0` and `cacheScope: "private"`, including for +`2026-07-28` discovery. Protocol negotiation and envelopes remain SDK-owned; +legacy and modern clients are verified in separate stdio sessions using +`shared/fixtures/ide-api/mcp-protocol.json`. ## Capabilities @@ -104,7 +128,7 @@ discontinuity explicitly. Pass the cursor only with its original operation ID. Failures contain `error: {code, message}`; callers must check `error` rather than infer success from a transport response. Shared errors include `INVALID_ARGUMENTS`, -`NOT_SUPPORTED`, `PERMISSION_DENIED`, `BUSY`, `DISCONNECTED`, and `TIMEOUT`. +`NOT_SUPPORTED`, `PERMISSION_DENIED`, `BUSY`, `DISCONNECTED`, `DISPATCHER_UNAVAILABLE`, and `TIMEOUT`. Application failures include `NOT_READY`, `NOT_FOUND`, `NOT_AVAILABLE`, `WORKSPACE_CLOSED`, `OPERATION_EXPIRED`, `LAUNCH_FAILED`, and `OPERATION_FAILED`. MCP returns these as visible tool errors (`isError: true`) with structured content. diff --git a/shared/contracts/rust-core-api.md b/shared/contracts/rust-core-api.md index 0d4115ed60..680fab2db2 100644 --- a/shared/contracts/rust-core-api.md +++ b/shared/contracts/rust-core-api.md @@ -4062,6 +4062,11 @@ Fixtures live in `shared/fixtures/process-lifecycle/settle-v1.json`. `lithe-ide-host` adapter. This local host command is not remotely exposed. See [IDE API v1](ide-api/v1.md) for the allowlisted plugin/MCP capabilities, connection ownership, authorization, output cursors and shutdown semantics. +Native Rust hosts can attach an owner-checked, connection-lifetime callback using +`attach_ide_request_dispatcher`; this is separate from the request-scoped Git/Agent +progress callback. It forwards notification IDs only. `ideHost.control` action +`claim` atomically returns an unexpired, unclaimed request or null before any +application action. Closing the connection releases the callback. ### Java service hot replacement diff --git a/shared/fixtures/ide-api/mcp-protocol.json b/shared/fixtures/ide-api/mcp-protocol.json new file mode 100644 index 0000000000..3c87db10db --- /dev/null +++ b/shared/fixtures/ide-api/mcp-protocol.json @@ -0,0 +1,8 @@ +{ + "version": 1, + "legacyProtocolVersion": "2025-11-25", + "modernProtocolVersion": "2026-07-28", + "cachePolicy": { "ttlMs": 0, "cacheScope": "private" }, + "toolCount": 13, + "modes": ["legacy", "modern", "modern-after-initialize"] +} diff --git a/shared/i18n/locales/en-US.json b/shared/i18n/locales/en-US.json index ad4f9cdbb1..b9eb70911b 100644 --- a/shared/i18n/locales/en-US.json +++ b/shared/i18n/locales/en-US.json @@ -7666,6 +7666,9 @@ "settings.mcp.enable": "Enable MCP", "settings.mcp.openProject": "Open a project to configure MCP access for agents.", "settings.mcp.plugins": "Enabled plugins declaring IDE access can also use this project grant.", + "settings.mcp.status.active": "Last tool call succeeded", + "settings.mcp.status.failed": "Last request failed · access remains enabled", + "settings.mcp.status.waiting": "Authorized · waiting for a tool call", "settings.mcp.title": "MCP Configuration", "settings.navigation": "Settings navigation", "settings.noMatching": "No settings found", diff --git a/shared/i18n/locales/zh-CN.json b/shared/i18n/locales/zh-CN.json index a481ae952c..44f5b1b823 100644 --- a/shared/i18n/locales/zh-CN.json +++ b/shared/i18n/locales/zh-CN.json @@ -7666,6 +7666,9 @@ "settings.mcp.enable": "开启 MCP", "settings.mcp.openProject": "打开项目后,可在这里配置 Agent 的 MCP 访问权限。", "settings.mcp.plugins": "声明 IDE 权限的已启用插件也可使用此项目授权。", + "settings.mcp.status.active": "最近一次工具调用成功", + "settings.mcp.status.failed": "最近一次请求失败 · 授权仍有效", + "settings.mcp.status.waiting": "已授权 · 等待工具调用", "settings.mcp.title": "MCP 配置", "settings.navigation": "设置导航", "settings.noMatching": "未找到设置", diff --git a/shared/i18n/messages.json b/shared/i18n/messages.json index 471f531706..b27789f42d 100644 --- a/shared/i18n/messages.json +++ b/shared/i18n/messages.json @@ -71605,6 +71605,27 @@ "windows" ] }, + { + "id": "settings.mcp.status.active", + "description": "Windows IDE MCP request status: active", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mcp.status.failed", + "description": "Windows IDE MCP request status: failed", + "platforms": [ + "windows" + ] + }, + { + "id": "settings.mcp.status.waiting", + "description": "Windows IDE MCP request status: waiting", + "platforms": [ + "windows" + ] + }, { "id": "settings.mcp.title", "description": "Windows UI: settings.mcp.title", diff --git a/shared/platform-feature-matrix/features/ide-mcp-connection.json b/shared/platform-feature-matrix/features/ide-mcp-connection.json index 5eadaf9dfd..ad9ef51195 100644 --- a/shared/platform-feature-matrix/features/ide-mcp-connection.json +++ b/shared/platform-feature-matrix/features/ide-mcp-connection.json @@ -12,7 +12,8 @@ "macos/Sources/Lithe/Views/App/McpSettingsView.swift", "macos/Tests/LitheTests/IdeCapabilitiesTests.swift", "rust/lithe-ide-host/src/lib.rs", - "macos/Sources/Lithe/Views/App/SettingsView.swift" + "macos/Sources/Lithe/Views/App/SettingsView.swift", + "scripts/test-ide-mcp.mjs" ] }, "windows": { @@ -24,10 +25,12 @@ "windows/tauri/src/features/host-api/ide-capabilities.test.ts", "rust/lithe-ide-host/src/lib.rs", "windows/tauri/src/features/settings/components/mcp-configuration-settings.tsx", - "windows/tauri/src-tauri/src/platform.rs" + "windows/tauri/src-tauri/src/platform.rs", + "windows/tauri/src/features/host-api/mcp-lifecycle.test.ts", + "windows/tauri/src/features/host-api/mcp-lifecycle.ts" ] }, "owner": "Agent", - "notes": "macOS 设置页把 MCP 授权整理为“服务器资源库 + Lithe IDE 访问”两段:资源库负责第三方服务器发现与编辑,见独立能力 mcp-server-library;Lithe IDE 访问段保留状态徽标、状态卡、项目权限和 Agent 配置,安全说明收进 ⓘ。开启授权时还会把随包的 lithe-mcp 写入当前项目的 Claude Code(projects..mcpServers)与 Codex(<项目>/.codex/config.toml)配置,并在关闭授权或项目时按账本清理;全局配置不受影响。 Windows 原生开启请求使用 ownerId 绑定当前窗口;关闭窗口仍使用 closeOwner 的 ownerID,避免严格字段校验拒绝开启请求。", - "verification": "检查设置侧栏 MCP 配置入口与搜索跳转;未打开项目时资源库照常列出全局服务器,Lithe IDE 访问段显示打开项目提示;切换项目后展示对应授权。双端打包启动后开启连接,配置真实 Agent,验证工具发现、默认拒绝写入、启用权限、禁用、项目关闭和多窗口隔离。macOS 打开 MCP 配置页确认状态徽标与状态卡文案跟随启用状态变化,header ⓘ 可打开完整安全说明,配置代码块只在存在配置时出现且可选中复制;启用后确认项目级 lithe 条目写入两个 Agent 的配置,禁用后条目清理,外部修改过的条目保留并提示。运行前后对比安装目录文件清单与哈希;当前 Linux 仅执行共享协议及前端验证,原生运行待验证。 Windows 验证开启请求通过真实 Host 解码、客户端不能覆盖窗口所有者、关闭其他窗口保留连接及所属窗口关闭删除连接文件。" + "notes": "macOS 设置页把 MCP 授权整理为“服务器资源库 + Lithe IDE 访问”两段:资源库负责第三方服务器发现与编辑,见独立能力 mcp-server-library;Lithe IDE 访问段保留状态徽标、状态卡、项目权限和 Agent 配置,安全说明收进 ⓘ。开启授权时还会把随包的 lithe-mcp 写入当前项目的 Claude Code(projects..mcpServers)与 Codex(<项目>/.codex/config.toml)配置,并在关闭授权或项目时按账本清理;全局配置不受影响。 Windows 原生开启请求使用 ownerId 绑定当前窗口;关闭窗口仍使用 closeOwner 的 ownerID,避免严格字段校验拒绝开启请求。 Windows 改用原生请求通道通知和执行前 claim,取消 WebView 轮询心跳依赖;过期、取消和重复通知不会执行操作,单次失败保留授权且不重放操作。设置页分别展示授权和最近请求结果。共享 MCP helper 为新版协议补齐 ttlMs/cacheScope。", + "verification": "检查设置侧栏 MCP 配置入口与搜索跳转;未打开项目时资源库照常列出全局服务器,Lithe IDE 访问段显示打开项目提示;切换项目后展示对应授权。双端打包启动后开启连接,配置真实 Agent,验证工具发现、默认拒绝写入、启用权限、禁用、项目关闭和多窗口隔离。macOS 打开 MCP 配置页确认状态徽标与状态卡文案跟随启用状态变化,header ⓘ 可打开完整安全说明,配置代码块只在存在配置时出现且可选中复制;启用后确认项目级 lithe 条目写入两个 Agent 的配置,禁用后条目清理,外部修改过的条目保留并提示。运行前后对比安装目录文件清单与哈希;当前 Linux 仅执行共享协议及前端验证,原生运行待验证。 Windows 验证开启请求通过真实 Host 解码、客户端不能覆盖窗口所有者、关闭其他窗口保留连接及所属窗口关闭删除连接文件。 验证旧版、新版直接发现与初始化后新版发现三个独立 stdio 会话;Windows 验证原生通知、过期领取拒绝、失败后新请求恢复、最小化与休眠恢复、关闭后通道释放及无重复执行。" } diff --git a/windows/tauri/src-tauri/src/platform.rs b/windows/tauri/src-tauri/src/platform.rs index 48c518b2d9..78b38ed95b 100644 --- a/windows/tauri/src-tauri/src/platform.rs +++ b/windows/tauri/src-tauri/src/platform.rs @@ -24,6 +24,7 @@ pub async fn platform_invoke( mut args: Value, git_events: Option, agent_events: Option, + ide_events: Option, git_execution: Option, ) -> Result { let log_manager = webview @@ -44,6 +45,7 @@ pub async fn platform_invoke( let git_events = git_events.map(|id| id.channel_on::<_, Value>(webview.clone())); // An adapter install reports its npm transfer on its own channel, so the // panel can show progress for as long as the download runs. + let ide_events = ide_events.map(|id| id.channel_on::<_, Value>(webview.clone())); let agent_events = agent_events.map(|id| id.channel_on::<_, Value>(webview)); let preserve_history_rewrite = is_reviewed_history_rewrite(&command, &args); let preserve_stash_restore = command == "git_pull" @@ -116,14 +118,17 @@ pub async fn platform_invoke( return lithe_core::execute_json_with_events(&request, events); } let response = lithe_core::execute_json(&request); - if let Some((app, owner)) = ide_owner { + if let Some((app, owner)) = &ide_owner { // Destruction can run before a blocking open registers its host. Clean // up here as well, even if the WebView's awaiting IPC future is gone. - if app.get_webview_window(&owner).is_none() { - crate::core::close_ide_hosts(&owner); + if app.get_webview_window(owner).is_none() { + crate::core::close_ide_hosts(owner); return json!({"ok":false,"error":{"message":"The authorized project window was closed"}}).to_string(); } } + if let (Some((_, owner)), Some(channel)) = (&ide_owner, ide_events) { + return attach_ide_events(&response, owner, channel); + } response }) .await @@ -153,6 +158,46 @@ pub async fn platform_invoke( ) } +// The channel is owned by the native broker, not by the lifetime of the open IPC. +// Closing the host drops it. Notifications cannot execute work without a fresh claim. +fn attach_ide_events(response: &str, owner: &str, channel: Channel) -> String { + let Ok(envelope) = serde_json::from_str::(response) else { + return response.into(); + }; + if envelope["ok"] != true { + return response.into(); + } + let Some(host_id) = envelope.pointer("/data/hostID").and_then(Value::as_str) else { + return response.into(); + }; + let event_host = host_id.to_owned(); + let result = lithe_core::attach_ide_request_dispatcher( + host_id, + owner, + Arc::new(move |request_id| { + channel + .send(json!({"hostID":event_host,"requestID":request_id})) + .map_err(|error| error.to_string()) + }), + ); + if let Err(error) = result { + let cleanup = lithe_core::execute_json( + &json!({"command":"ideHost.control", "payload":{ + "action":"close", "arguments":{"hostID":host_id} + }}) + .to_string(), + ); + if serde_json::from_str::(&cleanup) + .ok() + .is_none_or(|value| value["ok"] != true) + { + eprintln!("Could not close unattached IDE host"); + } + return json!({"ok":false,"error":{"message":error}}).to_string(); + } + response.into() +} + /// IDE operation output may describe a failed process while the inspection itself /// succeeded. Preserve its exit code and output instead of applying Git's legacy /// conversion of nonzero exit codes into rejected IPC calls. @@ -959,6 +1004,105 @@ mod tests { ); } + #[test] + fn mcp_native_channel_delivers_and_claims_without_frontend_polling() { + use serde_json::{json, Value}; + use std::io::{Read, Write}; + use std::sync::{Arc, Mutex}; + use std::time::Duration; + use tauri::ipc::{Channel, InvokeResponseBody}; + + fn control(action: &str, arguments: Value) -> Value { + serde_json::from_str(&lithe_core::execute_json(&json!({"command":"ideHost.control", "payload":{ "action":action, "arguments":arguments }}).to_string())).unwrap() + } + struct Fixture { + root: std::path::PathBuf, + host: String, + } + impl Drop for Fixture { + fn drop(&mut self) { + control("close", json!({"hostID":self.host})); + let _ = std::fs::remove_dir_all(&self.root); + } + } + let root = std::env::temp_dir().join(format!( + "lithe-mcp-channel-{}-{}", + std::process::id(), + super::REQUEST_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + )); + std::fs::create_dir_all(&root).unwrap(); + let mut fixture = Fixture { + root: root.clone(), + host: String::new(), + }; + let helper = root.join("helper.exe"); + std::fs::write(&helper, b"immutable").unwrap(); + let opened = control( + "open", + json!({"directory":root.join("data"), "workspaceKey":"fixture", "helperPath":helper, + "permissions":{"configure":false,"execute":false}, "ownerId":"fixture-window"}), + ); + assert_eq!(opened["ok"], true, "{opened}"); + fixture.host = opened["data"]["hostID"].as_str().unwrap().into(); + let captured = Arc::new(Mutex::new(Vec::::new())); + let recorder = captured.clone(); + let channel = Channel::::new(move |body| { + let InvokeResponseBody::Json(text) = body else { + panic!("expected JSON") + }; + let event: Value = serde_json::from_str(&text).unwrap(); + let claimed = control("claim", event.clone()); + assert_eq!(claimed["data"]["request"]["name"], "lithe_project_inspect"); + let delivered = control( + "respond", + json!({"hostID":event["hostID"],"requestID":event["requestID"],"result":{"workspaceID":"fixture"}}), + ); + assert_eq!(delivered["data"]["delivered"], true); + recorder.lock().unwrap().push(event); + Ok(()) + }); + let attached: Value = serde_json::from_str(&super::attach_ide_events( + &opened.to_string(), + "fixture-window", + channel, + )) + .unwrap(); + assert_eq!(attached["ok"], true, "{attached}"); + let descriptor_path = opened["data"]["configuration"]["mcpServers"]["lithe"]["args"][1] + .as_str() + .unwrap(); + let descriptor: Value = + serde_json::from_slice(&std::fs::read(descriptor_path).unwrap()).unwrap(); + let address: std::net::SocketAddr = descriptor["endpoint"] + .as_str() + .unwrap() + .strip_prefix("http://") + .unwrap() + .strip_suffix("/call") + .unwrap() + .parse() + .unwrap(); + let mut stream = + std::net::TcpStream::connect_timeout(&address, Duration::from_secs(2)).unwrap(); + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .unwrap(); + stream + .set_write_timeout(Some(Duration::from_secs(2))) + .unwrap(); + let body = r#"{"name":"lithe_project_inspect","arguments":{}}"#; + write!(stream,"POST /call HTTP/1.1\r\nHost: localhost\r\nAuthorization: Bearer {}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",descriptor["token"].as_str().unwrap(),body.len(),body).unwrap(); + let mut response = String::new(); + stream.take(65536).read_to_string(&mut response).unwrap(); + assert!( + response.contains(r#""workspaceID":"fixture""#), + "{response}" + ); + assert_eq!(captured.lock().unwrap().len(), 1); + assert_eq!(captured.lock().unwrap()[0]["hostID"], fixture.host); + assert_eq!(std::fs::read(helper).unwrap(), b"immutable"); + } + #[test] fn mcp_owner_binding_leaves_close_owner_and_other_commands_unchanged() { for (command, mut args) in [ diff --git a/windows/tauri/src/features/host-api/ide-capabilities.test.ts b/windows/tauri/src/features/host-api/ide-capabilities.test.ts index 9cf554e795..91bf5dc4f6 100644 --- a/windows/tauri/src/features/host-api/ide-capabilities.test.ts +++ b/windows/tauri/src/features/host-api/ide-capabilities.test.ts @@ -199,3 +199,17 @@ test("Maven profile configuration adds only undeclared profiles as custom entrie expect(setSelectedProfiles).toHaveBeenCalledWith(["dev", "ci"]); expect(saveLocalConfiguration).toHaveBeenCalledTimes(1); }); + + +test("MCP deadline is rechecked after native validation before accepting a workflow", async () => { + const { api, dependencies } = fixture(); + let allowed = true; + dependencies.validate.mockImplementationOnce(async () => { + allowed = false; + return { ok: true, mutation: true }; + }); + const result = await api.call("lithe_environment_configure", { javaHomePath: "fixture" }, () => allowed); + expect((result as { error: { code: string } }).error.code).toBe("TIMEOUT"); + expect(dependencies.loadProjectEnvironment).not.toHaveBeenCalled(); + expect(dependencies.saveProjectEnvironmentSettings).not.toHaveBeenCalled(); +}, 1000); diff --git a/windows/tauri/src/features/host-api/ide-capabilities.ts b/windows/tauri/src/features/host-api/ide-capabilities.ts index 324682be09..ccada92048 100644 --- a/windows/tauri/src/features/host-api/ide-capabilities.ts +++ b/windows/tauri/src/features/host-api/ide-capabilities.ts @@ -136,14 +136,22 @@ export class IdeCapabilities { }); return result.sort((a, b) => a.operationID.localeCompare(b.operationID)); } - async call(name: string, args: Record): Promise { + async call( + name: string, + args: Record, + canStart: () => boolean = () => true, + ): Promise { let ownsMutation = false; try { if (!this.isCurrent()) return fail("WORKSPACE_CLOSED", "The authorized project was closed or replaced"); + if (!canStart()) return fail("TIMEOUT", "The request expired before execution"); const validation = await this.dependencies.validate(name, args, this.permissions); if (!validation.ok) return validation; if (!this.isCurrent()) return fail("WORKSPACE_CLOSED", "The authorized project was closed"); + // Native validation may resolve after a suspended WebView resumes. Only + // entering the existing product workflow accepts execution; never replay it. + if (!canStart()) return fail("TIMEOUT", "The request expired before execution"); if (validation.mutation && this.mutationInFlight) return fail("BUSY", "Another configuration or launch request is in progress"); if (validation.mutation) { diff --git a/windows/tauri/src/features/host-api/mcp-connection.ts b/windows/tauri/src/features/host-api/mcp-connection.ts index 8ca868c92a..9874b63d89 100644 --- a/windows/tauri/src/features/host-api/mcp-connection.ts +++ b/windows/tauri/src/features/host-api/mcp-connection.ts @@ -1,5 +1,5 @@ import { create } from "zustand"; -import { invoke } from "@/platform/tauri-core"; +import { Channel, invoke } from "@/platform/tauri-core"; import { hostControl, IdeCapabilities, type IdePermissions } from "./ide-capabilities"; import { McpConnectionManager, type McpConnectionState } from "./mcp-lifecycle"; @@ -14,8 +14,17 @@ const manager = new McpConnectionManager( new IdeCapabilities(workspaceID, root, permissions), paths: () => invoke("ide_host_paths"), control: hostControl, - schedule: (callback) => setTimeout(callback, 200), - cancel: (timer) => clearTimeout(timer), + now: () => Date.now(), + open: (args, receive) => { + const channel = new Channel(); + channel.onmessage = (value) => { + const event = value as { hostID?: unknown; requestID?: unknown }; + if (typeof event?.hostID === "string" && typeof event?.requestID === "string") { + receive({ hostID: event.hostID, requestID: event.requestID }); + } + }; + return invoke("ideHost.control", { action: "open", arguments: args }, { ideEvents: channel }); + }, }, (state) => useMcpConnections.setState(state), ); diff --git a/windows/tauri/src/features/host-api/mcp-lifecycle.test.ts b/windows/tauri/src/features/host-api/mcp-lifecycle.test.ts index 68b296b44a..7ff7a70442 100644 --- a/windows/tauri/src/features/host-api/mcp-lifecycle.test.ts +++ b/windows/tauri/src/features/host-api/mcp-lifecycle.test.ts @@ -1,6 +1,6 @@ import { afterEach, expect, test } from "bun:test"; import { McpConnectionManager, type McpConnectionState } from "./mcp-lifecycle"; -import type { hostControl } from "./ide-capabilities"; +import type { hostControl, IdeRequest } from "./ide-capabilities"; function deferred() { let resolve!: (value: T) => void; @@ -11,7 +11,6 @@ function deferred() { }); return { promise, resolve, reject }; } - const cleanups = new Set<() => Promise>(); afterEach(async () => { try { @@ -25,31 +24,41 @@ function fixture() { let state: McpConnectionState = { connections: {}, error: null }; const openStarted = deferred(); const finishOpen = deferred(); - const oldPoll = deferred<{ requests: [] }>(); - const oldPollFinished = deferred(); + const claimStarted = deferred(); + const finishClaim = deferred(); const closed: string[] = []; + const calls: string[] = []; + const responses: string[] = []; const apis: { revoked: boolean }[] = []; - const timers = new Set>(); + const receivers = new Map< + string, + (event: { hostID: string; requestID: string }) => Promise + >(); + const requests = new Map(); + let failClaim = false; + let failRespond = false; let sequence = 0; + let now = 0; const control = async (action: string, args?: Record): Promise => { - if (action === "open") { - const hostID = String(++sequence); - openStarted.resolve(); - if (hostID === "1") await finishOpen.promise; - return { hostID, configuration: { hostID } }; - } if (action === "close") { closed.push(args!.hostID as string); return {}; } - if (action === "poll" && args!.hostID === "1") { - try { - return await oldPoll.promise; - } finally { - oldPollFinished.resolve(); - } + if (action === "claim") { + claimStarted.resolve(); + await finishClaim.promise; + if (failClaim) throw new Error("Transient IPC failure"); + const id = String(args!.requestID); + const request = requests.get(id) ?? null; + requests.delete(id); + return { request: request ? { ...request, remainingMilliseconds: 1000 } : null }; + } + if (action === "respond") { + if (failRespond) throw new Error("Response delivery unknown"); + responses.push(String(args!.requestID)); + return { delivered: true }; } - return { requests: [] }; + throw new Error(`Unexpected action ${action}: no polling is allowed`); }; const manager = new McpConnectionManager( { @@ -63,20 +72,23 @@ function fixture() { isCurrent() { return !this.revoked; }, - call: async () => ({}), + call: async (name: string) => { + calls.push(name); + return {}; + }, }; apis.push(api); return api; }, paths: async () => ({ directory: "fixture", helperPath: "fixture/helper" }), control: control as typeof hostControl, - schedule: () => { - const timer = {} as ReturnType; - timers.add(timer); - return timer; - }, - cancel: (timer) => { - if (timer) timers.delete(timer); + now: () => now, + open: async (_args, receive) => { + const hostID = String(++sequence); + receivers.set(hostID, receive); + openStarted.resolve(); + if (hostID === "1") await finishOpen.promise; + return { hostID, configuration: { hostID } }; }, }, (next) => { @@ -84,81 +96,167 @@ function fixture() { }, ); const cleanup = async () => { - openStarted.resolve(); finishOpen.resolve(); - oldPoll.resolve({ requests: [] }); - oldPollFinished.resolve(); + finishClaim.resolve(); await manager.closeAll(); + receivers.clear(); + requests.clear(); }; cleanups.add(cleanup); return { manager, apis, closed, - timers, + calls, + responses, openStarted, finishOpen, - oldPoll, - oldPollFinished, + claimStarted, + finishClaim, state: () => state, cleanup, + setTime: (value: number) => { + now = value; + }, + failClaim: (value: boolean) => { + failClaim = value; + }, + failRespond: (value: boolean) => { + failRespond = value; + }, + queue: (id: string) => + requests.set(id, { + requestID: id, + name: "lithe_run_start", + arguments: { configurationID: "fixture" }, + }), + receive: (hostID: string, requestID: string) => receivers.get(hostID)!({ hostID, requestID }), }; } const permissions = { configure: true, execute: true }; -test("closing a project during native open revokes access and closes the late host", async () => { +test("closing during open revokes access and closes the late native host", async () => { const f = fixture(); const opening = f.manager.enable("project", "fixture", permissions); - let closing: Promise | undefined; - try { - await f.openStarted.promise; - closing = f.manager.disable("project"); - expect(f.apis[0].revoked).toBe(true); - f.finishOpen.resolve(); - await Promise.all([opening, closing]); - expect(f.state().connections).toEqual({}); - expect(f.closed).toEqual(["1"]); - expect(f.timers.size).toBe(0); - } finally { - await f.cleanup(); - await Promise.all([opening, closing]); - } + await f.openStarted.promise; + const closing = f.manager.disable("project"); + expect(f.apis[0].revoked).toBe(true); + f.finishOpen.resolve(); + await Promise.all([opening, closing]); + expect(f.state().connections).toEqual({}); + expect(f.closed).toEqual(["1"]); }, 1000); -test("an old poll failure cannot revoke the replacement grant", async () => { +test("closing all includes opens that have not published configuration", async () => { const f = fixture(); + const opening = f.manager.enable("project", "fixture", permissions); + await f.openStarted.promise; + const closing = f.manager.closeAll(); f.finishOpen.resolve(); - try { - await f.manager.enable("project", "fixture", permissions); - await f.manager.enable("project", "fixture", permissions); - f.oldPoll.reject(new Error("old host was closed")); - await f.oldPollFinished.promise; - // The native double settles first; let the pump consume that rejection. - await Promise.resolve(); - expect(f.state().connections.project.hostID).toBe("2"); - expect(f.apis[1].revoked).toBe(false); - expect(f.state().error).toBeNull(); - expect(f.closed).toEqual(["1"]); - } finally { - await f.cleanup(); - } - expect(f.timers.size).toBe(0); + await Promise.all([opening, closing]); + expect(f.closed).toEqual(["1"]); + expect(f.apis[0].revoked).toBe(true); + expect(f.state().connections).toEqual({}); }, 1000); -test("closing all connections includes opens that have not published their configuration", async () => { +test("native notification before open completes waits for the owning grant", async () => { const f = fixture(); const opening = f.manager.enable("project", "fixture", permissions); - let closing: Promise | undefined; - try { - await f.openStarted.promise; - closing = f.manager.closeAll(); - f.finishOpen.resolve(); - await Promise.all([opening, closing]); - expect(f.closed).toEqual(["1"]); - expect(f.apis[0].revoked).toBe(true); - expect(f.state().connections).toEqual({}); - } finally { - await f.cleanup(); - await Promise.all([opening, closing]); - } + await f.openStarted.promise; + f.queue("request"); + f.finishClaim.resolve(); + const receiving = f.receive("1", "request"); + expect(f.calls).toEqual([]); + f.finishOpen.resolve(); + await Promise.all([opening, receiving]); + expect(f.calls).toEqual(["lithe_run_start"]); + expect(f.state().connections.project.status).toBe("active"); +}, 1000); + +test("expired and duplicate native notifications never execute an operation", async () => { + const f = fixture(); + f.finishOpen.resolve(); + f.finishClaim.resolve(); + await f.manager.enable("project", "fixture", permissions); + await f.receive("1", "expired"); + expect(f.calls).toEqual([]); + f.queue("live"); + await f.receive("1", "live"); + const previousSnapshot = f.state().connections.project; + await f.receive("1", "live"); + expect(f.calls).toEqual(["lithe_run_start"]); + f.queue("next"); + await f.receive("1", "next"); + expect(f.state().connections.project).not.toBe(previousSnapshot); +}, 1000); + +test("a failed claim preserves authorization and the next request recovers without reopening", async () => { + const f = fixture(); + f.finishOpen.resolve(); + f.finishClaim.resolve(); + await f.manager.enable("project", "fixture", permissions); + f.failClaim(true); + f.queue("failed"); + await f.receive("1", "failed"); + expect(f.state().connections.project.status).toBe("failed"); + expect(f.closed).toEqual([]); + expect(f.apis[0].revoked).toBe(false); + f.failClaim(false); + f.queue("next"); + await f.receive("1", "next"); + expect(f.state().connections.project.status).toBe("active"); + expect(f.state().connections.project.error).toBeNull(); + expect(f.calls).toEqual(["lithe_run_start"]); + expect(f.apis.length).toBe(1); +}, 1000); + +test("an ambiguous response failure never repeats an accepted mutation", async () => { + const f = fixture(); + f.finishOpen.resolve(); + f.finishClaim.resolve(); + await f.manager.enable("project", "fixture", permissions); + f.queue("mutation"); + f.failRespond(true); + await f.receive("1", "mutation"); + expect(f.state().connections.project.status).toBe("failed"); + f.failRespond(false); + await f.receive("1", "mutation"); + expect(f.calls).toEqual(["lithe_run_start"]); + expect(f.responses).toEqual([]); +}, 1000); + +test("closing or replacing a grant while claim is pending prevents late execution", async () => { + const f = fixture(); + f.finishOpen.resolve(); + await f.manager.enable("project", "fixture", permissions); + f.queue("old"); + const receiving = f.receive("1", "old"); + await f.claimStarted.promise; + await f.manager.enable("project", "fixture", permissions); + f.finishClaim.resolve(); + await receiving; + await f.receive("1", "late"); + expect(f.calls).toEqual([]); + expect(f.state().connections.project.hostID).toBe("2"); + expect(f.state().connections.project.status).toBe("waiting"); + expect(f.apis[1].revoked).toBe(false); + expect(f.closed).toEqual(["1"]); +}, 1000); + +test("suspending during claim delivery cannot start an expired mutation", async () => { + const f = fixture(); + f.finishOpen.resolve(); + await f.manager.enable("project", "fixture", permissions); + f.queue("old"); + const receiving = f.receive("1", "old"); + await f.claimStarted.promise; + f.setTime(2000); + f.finishClaim.resolve(); + await receiving; + expect(f.calls).toEqual([]); + expect(f.state().connections.project.status).toBe("failed"); + expect(f.state().connections.project.error).toContain("expired"); + f.queue("new"); + await f.receive("1", "new"); + expect(f.calls).toEqual(["lithe_run_start"]); }, 1000); diff --git a/windows/tauri/src/features/host-api/mcp-lifecycle.ts b/windows/tauri/src/features/host-api/mcp-lifecycle.ts index b2871bf3be..f53c7026ed 100644 --- a/windows/tauri/src/features/host-api/mcp-lifecycle.ts +++ b/windows/tauri/src/features/host-api/mcp-lifecycle.ts @@ -6,7 +6,8 @@ export interface McpConnection { configuration: string; api: Capability; closed: boolean; - timer?: ReturnType; + status: "waiting" | "active" | "failed"; + error: string | null; } export interface McpConnectionState { connections: Record; @@ -16,8 +17,14 @@ interface Dependencies { createAPI(workspaceID: string, root: string, permissions: IdePermissions): Capability; paths(): Promise<{ directory: string; helperPath: string }>; control: typeof hostControl; - schedule(callback: () => void): ReturnType; - cancel(timer: ReturnType | undefined): void; + now(): number; + open( + args: Record, + receive: (event: { hostID: string; requestID: string }) => Promise, + ): Promise<{ + hostID: string; + configuration: unknown; + }>; } interface Grant { api: Capability; @@ -38,7 +45,7 @@ export class McpConnectionManager { private publish() { const connections: Record = {}; for (const [id, grant] of this.grants) { - if (grant.connection) connections[id] = grant.connection; + if (grant.connection) connections[id] = { ...grant.connection }; } this.changed({ connections, error: this.error }); } @@ -59,7 +66,6 @@ export class McpConnectionManager { grant.api.revoke(); if (grant.connection) { grant.connection.closed = true; - this.dependencies.cancel(grant.connection.timer); } this.publish(); if (grant.connection) await this.close(grant.connection.hostID); @@ -94,9 +100,75 @@ export class McpConnectionManager { const paths = await this.dependencies.paths(); if (!current()) return; if (!grant.api.isCurrent()) throw new Error("The selected project was closed"); - const result = await this.dependencies.control<{ hostID: string; configuration: unknown }>( - "open", + const receive = async (event: { hostID: string; requestID: string }) => { + // Native delivery can precede the open IPC result. Wait for publication, + // then bind to this grant; never route late events to its replacement. + await grant.ready; + const connection = grant.connection; + if (!current() || !connection || event.hostID !== connection.hostID) return; + if (!grant.api.isCurrent()) { + await this.disable(workspaceID); + return; + } + try { + // Measure from before IPC, conservatively including outbound delivery time. + // A claimed reply may itself sit in a suspended WebView's queue. + const started = this.dependencies.now(); + const { request } = await this.dependencies.control<{ + request: (IdeRequest & { remainingMilliseconds: number }) | null; + }>("claim", event); + if (!current() || !grant.api.isCurrent() || !request) return; + let result: unknown; + const canStart = () => { + const elapsed = this.dependencies.now() - started; + return ( + Number.isFinite(request.remainingMilliseconds) && + elapsed >= 0 && + elapsed < request.remainingMilliseconds + ); + }; + try { + if (!canStart()) { + result = { + error: { + code: "TIMEOUT", + message: + "The request expired before execution. Inspect operations before retrying.", + }, + }; + } else { + result = await grant.api.call(request.name, request.arguments, canStart); + } + } catch (error) { + result = { error: { code: "OPERATION_FAILED", message: String(error) } }; + } + if (!current()) return; + const { delivered } = await this.dependencies.control<{ delivered: boolean }>("respond", { + hostID: connection.hostID, + requestID: request.requestID, + result, + }); + if (!current()) return; + const failure = (result as { error?: { message?: string } } | null)?.error; + connection.status = delivered && !failure ? "active" : "failed"; + connection.error = + failure?.message ?? + (delivered + ? null + : "The client no longer accepts this result. Inspect operations before retrying."); + this.publish(); + } catch (error) { + if (!current()) return; + // Delivery failure is not authorization revocation. A later request can + // succeed through the same channel; ambiguous mutations are never replayed. + connection.status = "failed"; + connection.error = String(error); + this.publish(); + } + }; + const result = await this.dependencies.open( { ...paths, workspaceKey: root, permissions }, + receive, ); if (!current() || !grant.api.isCurrent()) { await this.close(result.hostID); @@ -108,52 +180,11 @@ export class McpConnectionManager { configuration: JSON.stringify(result.configuration, null, 2), api: grant.api, closed: false, + status: "waiting", + error: null, }; grant.connection = connection; this.publish(); - const respond = async (request: IdeRequest) => { - let result: unknown; - try { - result = await grant.api.call(request.name, request.arguments); - } catch (error) { - result = { error: { code: "OPERATION_FAILED", message: String(error) } }; - } - if (!current()) return; - try { - await this.dependencies.control("respond", { - hostID: connection.hostID, - requestID: request.requestID, - result, - }); - } catch (error) { - if (current()) { - this.error = String(error); - this.publish(); - } - } - }; - const poll = async () => { - if (!current()) return; - if (!grant.api.isCurrent()) { - await this.disable(workspaceID); - return; - } - try { - const result = await this.dependencies.control<{ requests: IdeRequest[] }>("poll", { - hostID: connection.hostID, - }); - if (current()) for (const request of result.requests) void respond(request); - } catch (error) { - // A response from the previous grant must never close a replacement connection. - if (current()) { - this.error = String(error); - await this.disable(workspaceID); - } - return; - } - if (current()) connection.timer = this.dependencies.schedule(() => void poll()); - }; - void poll(); } catch (error) { if (current()) { this.grants.delete(workspaceID); diff --git a/windows/tauri/src/features/host-api/mcp-settings.tsx b/windows/tauri/src/features/host-api/mcp-settings.tsx index 64c3c732db..7ddf663e23 100644 --- a/windows/tauri/src/features/host-api/mcp-settings.tsx +++ b/windows/tauri/src/features/host-api/mcp-settings.tsx @@ -34,6 +34,11 @@ export function McpSettings({ workspaceID, root }: { workspaceID: string; root: /> {t("settings.mcp.allowExecute")} + {connection && ( +

+ {t(`settings.mcp.status.${connection.status}`)} +

+ )}