diff --git a/docs/development/OpenNexus生产化实施进度-2026-09-08.md b/docs/development/OpenNexus生产化实施进度-2026-09-08.md index f2ef2e7..e6c8574 100644 --- a/docs/development/OpenNexus生产化实施进度-2026-09-08.md +++ b/docs/development/OpenNexus生产化实施进度-2026-09-08.md @@ -495,3 +495,15 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写 - 协议依据 [MCP lifecycle](https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle)、[MCP tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools)、[MCP cancellation](https://modelcontextprotocol.io/specification/2025-11-25/basic/utilities/cancellation)。此组件不是完整安装实例运行时,安装身份/在线信任/工具 schema 与授权路由、原生实例注册,以及全部沙箱资源策略仍须完成,第三方执行能力保持禁用。 - 新增长时原生验收已显式执行:实际 tools/call 挂起 60.0015261 秒后得到 EXTENSION_TOOL_DEADLINE_EXCEEDED,服务及后代最终清空;Host 此后保存笔记并重新打开 Workspace 校验正文成功。该验收完整夹具总耗时 75.64 秒,包含握手/其他原生场景。日志 `.build/extension-mcp-sixty-seconds.log`。 - 常规扩展回归为 60 passed / 5 ignored,其中三个 Job 辅助入口由父测试驱动,新 MCP 长时项已另行显式通过,旧底层 60 秒项本轮未重跑。desktop 全目标 Clippy -D warnings 通过。日志 `.build/extension-mcp-tests.log`、`.build/extension-mcp-clippy.log`。这些是工具期限及协议链路证据,仍不代替 C-04 中内存/scratch/CPU/进程及完整 Host 验收。 + + +## 增量:MCP 工具目录、离线契约与变更失效 + +- 新增 extension_mcp_tools::Catalog / Tool;最多 100 页、500 个工具、4 MiB 目录响应总量,检测重复工具名与循环游标,只有完整目录验证成功才发布。名称按 MCP 推荐的 ASCII 字母(含大小写)、数字、点、下划线与连字符处理,大小写敏感;不再把原开发 Python 的小写限定直接套到原生 MCP。 +- 每个工具输入/输出 schema 以 Draft 2020-12 离线编译,使用线性 regex 模式,限制单 schema 64 KiB、树深 24、节点数 4096。与 Host 配置契约的有界执行策略一致,本阶段拒绝所有外部及本地/递归引用、未知 schema 方言和需要任务增强执行的工具,不进行网络或文件解析。该支持范围需在后续安装兼容性界面明确呈现。 +- Session.refresh_tools 先撤下旧目录,再在分页请求的总体 20 秒等待预算内构建新目录。未发现目录、未知工具或不满足输入 schema 的参数均在发送前拒绝;参数还限制为 256 KiB 的有界对象。远端 annotations、描述和 metadata 不产生权限,也未被转换为可免确认的工具属性。 +- 成功工具结果按已选中的输出 schema 检查 structuredContent,限制整体 256 KiB、最多 128 个内容项,并校验文本、图片/音频 base64、资源链接和嵌入资源的基础结构。isError=true 允许不带成功结构化结果。资源 URI 仅作为数据校验,不会自动抓取或打开;具体媒体渲染与资源获取仍需走各自授权路径。 +- 接收 tools/list_changed 后立即清除可调用目录;下一调用前也处理已经排队的空闲通知,避免继续使用已知失效的目录。目录建立期间发生变化不会发布混合版本。进行中的调用保留选中契约的 Arc,用原契约校验结果,新的调用必须重新发现目录;正式实例后台循环仍需持续处理空闲事件并与 UI/Agent 的目录版本同步。 +- 单元测试覆盖分页、重复、循环、总量/页数限制,必填项/类型/额外参数、结构化结果、无效 base64、所有引用类型、未知方言/不支持正则/任务要求与过深输入。真实 AppContainer 测试新增两页目录、发现前/未知名/无效参数拒绝、错误 structuredContent、调用中及空闲时工具变更;全部经实际原生 Session/Pump 运行。 +- 62 项扩展回归通过,5 项 ignored 为三个父测试驱动 Job 辅助入口及两个需显式执行的长时验收,本轮未重跑长时项;上一轮实际 MCP 60 秒验收仍为其当时提交的证据。desktop 全目标 Clippy -D warnings 通过。日志 `.build/extension-mcp-catalog-tests.log`、`.build/extension-mcp-catalog-clippy.log`。协议依据 [MCP tools](https://modelcontextprotocol.io/specification/2025-11-25/server/tools)。 +- 工具发现和校验不等于用户批准执行。生产入口仍需完成实际安装实例注册、来源/活动版本/在线信任与工具目录摘要绑定、用户调用授权及 Core 路由;还需完成全部沙箱资源/并发攻击验证和其他生产化验收,第三方执行能力保持禁用。 diff --git a/frontend/src-tauri/src/extension_container.rs b/frontend/src-tauri/src/extension_container.rs index b5b3707..22460ee 100644 --- a/frontend/src-tauri/src/extension_container.rs +++ b/frontend/src-tauri/src/extension_container.rs @@ -871,6 +871,9 @@ mod tests { "mcp_cancel", "mcp_remote_error", "mcp_bad_version", + "mcp_pages", + "mcp_bad_result", + "mcp_idle_change", ]; if _mcp_deadline { mcp_modes.push("mcp_deadline"); @@ -911,8 +914,26 @@ mod tests { crate::extension_mcp::PROTOCOL_VERSION ); assert_eq!( - session.list_tools(None, &cancel).unwrap()["tools"][0]["name"], - "echo" + session + .call_tool("echo", serde_json::json!({}), &cancel) + .unwrap_err() + .code, + "EXTENSION_MCP_CATALOG_REQUIRED" + ); + assert_eq!(session.refresh_tools(&cancel).unwrap()[0].name, "echo"); + assert_eq!( + session + .call_tool("missing", serde_json::json!({}), &cancel) + .unwrap_err() + .code, + "EXTENSION_MCP_TOOL_NOT_FOUND" + ); + assert_eq!( + session + .call_tool("echo", serde_json::json!({"unexpected":1}), &cancel) + .unwrap_err() + .code, + "EXTENSION_MCP_ARGUMENTS_INVALID" ); let cancellation = if mode == "mcp_cancel" { let flag = Arc::clone(&cancel); @@ -926,6 +947,22 @@ mod tests { let tool_started = std::time::Instant::now(); let result = session.call_tool("echo", serde_json::json!({}), &cancel); match mode { + "mcp_bad_result" => assert_eq!( + result.unwrap_err().code, + "EXTENSION_MCP_TOOL_RESULT_INVALID" + ), + "mcp_idle_change" => { + assert_eq!(result.unwrap()["structuredContent"]["ok"], true); + std::thread::sleep(std::time::Duration::from_millis(200)); + assert_eq!( + session + .call_tool("echo", serde_json::json!({}), &cancel) + .unwrap_err() + .code, + "EXTENSION_MCP_CATALOG_REQUIRED" + ); + assert!(session.take_tools_changed()); + } "mcp_deadline" => { assert_eq!( result.unwrap_err().code, @@ -977,11 +1014,18 @@ mod tests { assert_eq!(result.unwrap()["content"][0]["text"], "native MCP success"); assert!(session.take_tools_changed()); assert!(!session.take_tools_changed()); + assert_eq!( + session + .call_tool("echo", serde_json::json!({}), &cancel) + .unwrap_err() + .code, + "EXTENSION_MCP_CATALOG_REQUIRED" + ); } } if mode == "mcp_cancel" || mode == "mcp_wrong_id" { assert_eq!( - session.list_tools(None, &cancel).unwrap_err().code, + session.refresh_tools(&cancel).err().unwrap().code, "EXTENSION_MCP_SESSION_FAILED" ); } diff --git a/frontend/src-tauri/src/extension_mcp.rs b/frontend/src-tauri/src/extension_mcp.rs index 6691216..930294d 100644 --- a/frontend/src-tauri/src/extension_mcp.rs +++ b/frontend/src-tauri/src/extension_mcp.rs @@ -92,6 +92,7 @@ pub struct Session<'a, 'p> { tools: bool, failed: bool, tools_changed: bool, + catalog: Option, } impl<'a, 'p> Session<'a, 'p> { pub fn new(process: &'a Running<'p>, io: HostIo) -> Result { @@ -103,6 +104,7 @@ impl<'a, 'p> Session<'a, 'p> { tools: false, failed: false, tools_changed: false, + catalog: None, }) } pub fn initialize(&mut self, cancel: &AtomicBool) -> Result { @@ -140,7 +142,43 @@ impl<'a, 'p> Session<'a, 'p> { self.ready = true; Ok(version.unwrap().into()) } - pub fn list_tools(&mut self, cursor: Option<&str>, cancel: &AtomicBool) -> Result { + pub fn refresh_tools( + &mut self, + cancel: &AtomicBool, + ) -> Result> { + self.require_tools()?; + self.drain_pending()?; + self.catalog = None; + self.tools_changed = false; + let started = Instant::now(); + let catalog = crate::extension_mcp_tools::Catalog::discover(|cursor| { + let remaining = Duration::from_secs(20).saturating_sub(started.elapsed()); + if remaining.is_zero() { + return Err(HostError::new("EXTENSION_MCP_CATALOG_TIMEOUT")); + } + self.list_tools_page(cursor, cancel, remaining.min(Duration::from_secs(10))) + }); + let catalog = match catalog { + Ok(catalog) => catalog, + Err(error) => { + self.abort(); + return Err(error); + } + }; + self.drain_pending()?; + if self.tools_changed { + return Err(HostError::new("EXTENSION_MCP_CATALOG_CHANGED")); + } + let descriptions = catalog.descriptions(); + self.catalog = Some(catalog); + Ok(descriptions) + } + fn list_tools_page( + &mut self, + cursor: Option<&str>, + cancel: &AtomicBool, + budget: Duration, + ) -> Result { self.require_tools()?; if cursor.is_some_and(|s| s.is_empty() || s.len() > 1024) { return Err(invalid()); @@ -148,7 +186,7 @@ impl<'a, 'p> Session<'a, 'p> { let value = self.request( "tools/list", cursor.map_or_else(|| json!({}), |c| json!({"cursor":c})), - Duration::from_secs(10), + budget, cancel, )?; if !value["tools"] @@ -170,6 +208,7 @@ impl<'a, 'p> Session<'a, 'p> { cancel: &AtomicBool, ) -> Result { self.require_tools()?; + self.drain_pending()?; if name.is_empty() || name.len() > 256 || name.chars().any(char::is_control) @@ -177,18 +216,21 @@ impl<'a, 'p> Session<'a, 'p> { { return Err(invalid()); } + let tool = self + .catalog + .as_ref() + .ok_or_else(|| HostError::new("EXTENSION_MCP_CATALOG_REQUIRED"))? + .tool(name)?; + tool.validate_arguments(&arguments)?; let value = self.request( "tools/call", json!({"name":name,"arguments":arguments}), Duration::from_secs(60), cancel, )?; - if !value["content"].is_array() - || value.get("isError").is_some_and(|v| !v.is_boolean()) - || serde_json::to_vec(&value).map_err(|_| invalid())?.len() > 256 * 1024 - { + if let Err(error) = tool.validate_result(&value) { self.abort(); - return Err(HostError::new("EXTENSION_MCP_TOOL_RESULT_INVALID")); + return Err(error); } Ok(value) } @@ -204,6 +246,46 @@ impl<'a, 'p> Session<'a, 'p> { } Ok(()) } + fn server_message(&mut self, message: &Envelope) -> Result { + let Some(method) = &message.method else { + return Ok(false); + }; + if let Some(id) = &message.id { + self.send(if method == "ping" { json!({"jsonrpc":"2.0","id":id,"result":{}}) } + else { json!({"jsonrpc":"2.0","id":id,"error":{"code":-32601,"message":"Method not supported"}}) })?; + } else if method == "notifications/tools/list_changed" { + self.tools_changed = true; + self.catalog = None; + } + Ok(true) + } + /// Apply already-received notifications before selecting a cached contract. + /// The eventual registry loop must also call this while the instance is idle. + pub fn drain_pending(&mut self) -> Result<()> { + let result = (|| { + for _ in 0..128 { + self.process.check_authorization()?; + match self.pump.receive(Duration::ZERO) { + Err(error) if error.code == "EXTENSION_IO_TIMEOUT" => return Ok(()), + Err(error) => return Err(error), + Ok(Event::Closed) => { + return Err(HostError::new("EXTENSION_MCP_CONNECTION_CLOSED")) + } + Ok(Event::Frame(bytes)) => { + let message = decode(&bytes)?; + if !self.server_message(&message)? { + return Err(HostError::new("EXTENSION_MCP_UNEXPECTED_RESPONSE")); + } + } + } + } + Err(HostError::new("EXTENSION_IO_RATE_LIMITED")) + })(); + if result.is_err() { + self.abort(); + } + result + } fn send(&self, value: Value) -> Result<()> { self.pump.send_wait( serde_json::to_vec(&value).map_err(|_| invalid())?, @@ -259,14 +341,7 @@ impl<'a, 'p> Session<'a, 'p> { Err(error) => return Err(error), }; let message = decode(&bytes)?; - if let Some(method) = message.method { - if let Some(id) = message.id { - // No sampling/roots/elicitation capability was advertised. - self.send(if method == "ping" { json!({"jsonrpc":"2.0","id":id,"result":{}}) } - else { json!({"jsonrpc":"2.0","id":id,"error":{"code":-32601,"message":"Method not supported"}}) })?; - } else if method == "notifications/tools/list_changed" { - self.tools_changed = true; - } + if self.server_message(&message)? { continue; } if message.id != Some(json!(id)) { diff --git a/frontend/src-tauri/src/extension_mcp_tools.rs b/frontend/src-tauri/src/extension_mcp_tools.rs new file mode 100644 index 0000000..a40f612 --- /dev/null +++ b/frontend/src-tauri/src/extension_mcp_tools.rs @@ -0,0 +1,408 @@ +//! Bounded, offline MCP tool contracts. Descriptions/annotations are untrusted +//! data and never confer permissions. URI content is validated, never fetched. +use crate::workspace::{HostError, Result}; +use base64::Engine; +use serde::Serialize; +use serde_json::Value; +use std::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; +const MAX_TOOLS: usize = 500; +const MAX_PAGES: usize = 100; +const MAX_CATALOG_BYTES: usize = 4 * 1024 * 1024; +fn invalid() -> HostError { + HostError::new("EXTENSION_MCP_TOOL_SCHEMA_INVALID") +} +fn bounds(value: &Value, depth: usize, nodes: &mut usize, schema: bool) -> Result<()> { + *nodes += 1; + if depth > 24 || *nodes > 4096 { + return Err(HostError::new("EXTENSION_MCP_TOOL_LIMIT")); + } + match value { + Value::Object(map) => { + for (key, child) in map { + if schema && matches!(key.as_str(), "$ref" | "$dynamicRef" | "$recursiveRef") { + return Err(HostError::new("EXTENSION_MCP_SCHEMA_REFERENCE")); + } + bounds(child, depth + 1, nodes, schema)?; + } + } + Value::Array(items) => { + for child in items { + bounds(child, depth + 1, nodes, schema)?; + } + } + _ => {} + } + Ok(()) +} +fn bounded(value: &Value, bytes: usize, schema: bool) -> Result<()> { + bounds(value, 0, &mut 0, schema)?; + if serde_json::to_vec(value).map_err(|_| invalid())?.len() > bytes { + return Err(HostError::new("EXTENSION_MCP_TOOL_LIMIT")); + } + Ok(()) +} +fn compile(schema: &Value) -> Result { + bounded(schema, 64 * 1024, true)?; + if !schema.is_object() + || schema["type"] != "object" + || schema + .get("$schema") + .is_some_and(|v| v != "https://json-schema.org/draft/2020-12/schema") + { + return Err(invalid()); + } + jsonschema::options() + .offline() + .with_draft(jsonschema::Draft::Draft202012) + .with_pattern_options(jsonschema::PatternOptions::regex()) + .should_validate_formats(true) + .should_ignore_unknown_formats(false) + .build(schema) + .map_err(|_| invalid()) +} +#[derive(Clone, Serialize)] +pub struct Description { + pub name: String, + pub title: Option, + pub description: Option, + pub input_schema: Value, + pub output_schema: Option, +} +pub struct Tool { + description: Description, + input: jsonschema::Validator, + output: Option, +} +impl Tool { + fn parse(value: &Value) -> Result { + bounded(value, 192 * 1024, false)?; + let name = value["name"] + .as_str() + .filter(|s| { + !s.is_empty() + && s.len() <= 128 + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_.-".contains(&b)) + }) + .ok_or_else(invalid)?; + fn optional(value: &Value, key: &str, max: usize) -> Result> { + value + .get(key) + .map(|v| { + v.as_str() + .filter(|s| s.len() <= max) + .map(str::to_owned) + .ok_or_else(invalid) + }) + .transpose() + } + if let Some(execution) = value.get("execution") { + if !execution.is_object() + || execution + .get("taskSupport") + .is_some_and(|v| v != "optional" && v != "forbidden") + { + return Err(HostError::new("EXTENSION_MCP_TASKS_UNSUPPORTED")); + } + } + let input = compile(&value["inputSchema"])?; + let output = value.get("outputSchema").map(compile).transpose()?; + Ok(Self { + description: Description { + name: name.into(), + title: optional(value, "title", 256)?, + description: optional(value, "description", 16 * 1024)?, + input_schema: value["inputSchema"].clone(), + output_schema: value.get("outputSchema").cloned(), + }, + input, + output, + }) + } + pub fn validate_arguments(&self, arguments: &Value) -> Result<()> { + bounded(arguments, 256 * 1024, false)?; + if !arguments.is_object() || !self.input.is_valid(arguments) { + return Err(HostError::new("EXTENSION_MCP_ARGUMENTS_INVALID")); + } + Ok(()) + } + pub fn validate_result(&self, result: &Value) -> Result<()> { + bounded(result, 256 * 1024, false)?; + let bad = || HostError::new("EXTENSION_MCP_TOOL_RESULT_INVALID"); + if !result.is_object() || result.get("isError").is_some_and(|v| !v.is_boolean()) { + return Err(bad()); + } + let content = result["content"] + .as_array() + .filter(|v| v.len() <= 128) + .ok_or_else(bad)?; + fn uri(value: &Value) -> bool { + value + .as_str() + .is_some_and(|s| s.len() <= 2048 && reqwest::Url::parse(s).is_ok()) + } + fn binary(value: &Value) -> bool { + value + .as_str() + .is_some_and(|s| base64::engine::general_purpose::STANDARD.decode(s).is_ok()) + } + for item in content { + let valid = match item["type"].as_str() { + Some("text") => item["text"].is_string(), + Some("image" | "audio") => { + binary(&item["data"]) + && item["mimeType"].as_str().is_some_and(|s| { + s.len() <= 128 + && s.starts_with(if item["type"] == "image" { + "image/" + } else { + "audio/" + }) + && !s.chars().any(char::is_control) + }) + } + Some("resource_link") => { + uri(&item["uri"]) + && item["name"] + .as_str() + .is_some_and(|s| !s.is_empty() && s.len() <= 256) + } + Some("resource") => { + let resource = &item["resource"]; + uri(&resource["uri"]) + && match (resource.get("text"), resource.get("blob")) { + (Some(text), None) => text.is_string(), + (None, Some(blob)) => binary(blob), + _ => false, + } + } + _ => false, + }; + if !valid { + return Err(bad()); + } + } + if result + .get("structuredContent") + .is_some_and(|v| !v.is_object()) + { + return Err(bad()); + } + if result["isError"] != true { + if let Some(output) = &self.output { + if !result["structuredContent"].is_object() + || !output.is_valid(&result["structuredContent"]) + { + return Err(bad()); + } + } + } + Ok(()) + } +} +pub struct Catalog { + tools: BTreeMap>, +} +impl Catalog { + pub fn discover(mut fetch: impl FnMut(Option<&str>) -> Result) -> Result { + let mut tools = BTreeMap::new(); + let mut seen = BTreeSet::new(); + let mut cursor: Option = None; + let mut bytes = 0; + for _ in 0..MAX_PAGES { + let page = fetch(cursor.as_deref())?; + bytes += serde_json::to_vec(&page).map_err(|_| invalid())?.len(); + if bytes > MAX_CATALOG_BYTES { + return Err(HostError::new("EXTENSION_MCP_CATALOG_LIMIT")); + } + let entries = page["tools"].as_array().ok_or_else(invalid)?; + if entries.len() > MAX_TOOLS - tools.len() { + return Err(HostError::new("EXTENSION_MCP_CATALOG_LIMIT")); + } + for entry in entries { + let tool = Tool::parse(entry)?; + if tools + .insert(tool.description.name.clone(), Arc::new(tool)) + .is_some() + { + return Err(HostError::new("EXTENSION_MCP_TOOL_DUPLICATE")); + } + } + match page.get("nextCursor") { + None => return Ok(Self { tools }), + Some(next) => { + let next = next + .as_str() + .filter(|s| !s.is_empty() && s.len() <= 1024) + .ok_or_else(invalid)?; + if !seen.insert(next.to_owned()) { + return Err(HostError::new("EXTENSION_MCP_PAGINATION_CYCLE")); + } + cursor = Some(next.into()); + } + } + } + Err(HostError::new("EXTENSION_MCP_CATALOG_LIMIT")) + } + pub fn descriptions(&self) -> Vec { + self.tools + .values() + .map(|tool| tool.description.clone()) + .collect() + } + pub fn tool(&self, name: &str) -> Result> { + self.tools + .get(name) + .cloned() + .ok_or_else(|| HostError::new("EXTENSION_MCP_TOOL_NOT_FOUND")) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + fn tool() -> Value { + json!({"name":"Echo.V1","inputSchema":{"type":"object","required":["count"],"additionalProperties":false,"properties":{"count":{"type":"integer","minimum":1,"maximum":3}}},"outputSchema":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean"}}},"annotations":{"readOnlyHint":true}}) + } + #[test] + fn paginated_catalog_is_atomic_unique_and_bounded() { + let mut pages = 0; + let catalog = Catalog::discover(|cursor| { + pages += 1; + if pages == 1 { + assert!(cursor.is_none()); + Ok(json!({"tools":[tool()],"nextCursor":"two"})) + } else { + assert_eq!(cursor, Some("two")); + let mut second = tool(); + second["name"] = json!("second"); + Ok(json!({"tools":[second]})) + } + }) + .unwrap(); + assert_eq!(catalog.descriptions().len(), 2); + assert_eq!( + catalog.tool("missing").err().unwrap().code, + "EXTENSION_MCP_TOOL_NOT_FOUND" + ); + let mut pages = 0; + assert_eq!( + Catalog::discover(|_| { + pages += 1; + Ok(if pages == 1 { + json!({"tools":[tool()],"nextCursor":"two"}) + } else { + json!({"tools":[tool()]}) + }) + }) + .err() + .unwrap() + .code, + "EXTENSION_MCP_TOOL_DUPLICATE" + ); + assert_eq!( + Catalog::discover(|_| Ok(json!({"tools":[],"nextCursor":"same"}))) + .err() + .unwrap() + .code, + "EXTENSION_MCP_PAGINATION_CYCLE" + ); + assert_eq!( + Catalog::discover(|_| Ok(json!({"tools":vec![tool();501]}))) + .err() + .unwrap() + .code, + "EXTENSION_MCP_CATALOG_LIMIT" + ); + let mut pages = 0; + assert_eq!( + Catalog::discover(|_| { + pages += 1; + Ok(json!({"tools":[],"nextCursor":pages.to_string()})) + }) + .err() + .unwrap() + .code, + "EXTENSION_MCP_CATALOG_LIMIT" + ); + assert_eq!(pages, 100); + assert_eq!( + Catalog::discover(|_| Ok(json!({"tools":[],"padding":"x".repeat(MAX_CATALOG_BYTES)}))) + .err() + .unwrap() + .code, + "EXTENSION_MCP_CATALOG_LIMIT" + ); + } + #[test] + fn contracts_validate_arguments_structured_results_and_never_resolve_references() { + let contract = Tool::parse(&tool()).unwrap(); + contract.validate_arguments(&json!({"count":2})).unwrap(); + for arguments in [ + json!({}), + json!({"count":0}), + json!({"count":"2"}), + json!({"count":2,"extra":true}), + ] { + assert_eq!( + contract.validate_arguments(&arguments).unwrap_err().code, + "EXTENSION_MCP_ARGUMENTS_INVALID" + ); + } + let result = + json!({"content":[{"type":"text","text":"ok"}],"structuredContent":{"ok":true}}); + contract.validate_result(&result).unwrap(); + for result in [ + json!({"content":[]}), + json!({"content":[],"structuredContent":{"ok":"wrong"}}), + json!({"content":[{"type":"image","mimeType":"image/png","data":"invalid base64 !"}],"structuredContent":{"ok":true}}), + json!({"content":[{"type":"text","text":42}],"structuredContent":{"ok":true}}), + ] { + assert!(contract.validate_result(&result).is_err()); + } + contract + .validate_result( + &json!({"content":[{"type":"text","text":"tool failed"}],"isError":true}), + ) + .unwrap(); + for target in [ + "https://example.invalid/schema", + "file:///C:/private", + "#/$defs/recursive", + ] { + let mut value = tool(); + value["inputSchema"]["$ref"] = json!(target); + assert_eq!( + Tool::parse(&value).err().unwrap().code, + "EXTENSION_MCP_SCHEMA_REFERENCE" + ); + } + for bad in [ + json!({"type":"object","properties":{"value":{"pattern":"(?=x)"}}}), + json!({"type":"object","$schema":"unknown"}), + json!({"type":"array"}), + ] { + let mut value = tool(); + value["inputSchema"] = bad; + assert!(Tool::parse(&value).is_err()); + } + let mut value = tool(); + value["execution"] = json!({"taskSupport":"required"}); + assert_eq!( + Tool::parse(&value).err().unwrap().code, + "EXTENSION_MCP_TASKS_UNSUPPORTED" + ); + let mut deep = json!({}); + for _ in 0..25 { + deep = json!({"nested":deep}); + } + assert_eq!( + contract.validate_arguments(&deep).unwrap_err().code, + "EXTENSION_MCP_TOOL_LIMIT" + ); + } +} diff --git a/frontend/src-tauri/src/lib.rs b/frontend/src-tauri/src/lib.rs index c594108..ff3b033 100644 --- a/frontend/src-tauri/src/lib.rs +++ b/frontend/src-tauri/src/lib.rs @@ -87,3 +87,6 @@ pub mod extension_io; #[cfg(all(windows, feature = "desktop"))] pub mod extension_mcp; + +#[cfg(all(windows, feature = "desktop"))] +pub mod extension_mcp_tools; diff --git a/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs b/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs index 36729c6..3b8621a 100644 --- a/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs +++ b/frontend/src-tauri/tests/fixtures/sandbox_network_probe.rs @@ -15,7 +15,14 @@ fn main() { assert!(read(&mut input).contains("notifications/initialized")); let request = read(&mut input); assert!(request.contains("tools/list")); - reply(id(&request), r#"{"tools":[{"name":"echo","inputSchema":{"type":"object"}}]}"#); + if args[1] == "mcp_pages" { + reply(id(&request), r#"{"tools":[],"nextCursor":"second"}"#); + let page = read(&mut input); + assert!(page.contains("tools/list") && page.contains("second")); + reply(id(&page), r#"{"tools":[{"name":"echo","inputSchema":{"type":"object","additionalProperties":false},"outputSchema":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean"}}}}]}"#); + } else { + reply(id(&request), r#"{"tools":[{"name":"echo","inputSchema":{"type":"object","additionalProperties":false},"outputSchema":{"type":"object","required":["ok"],"properties":{"ok":{"type":"boolean"}}}}]}"#); + } let mut request = read(&mut input); assert!(request.contains("tools/call")); if args[1] == "mcp_cancel" || args[1] == "mcp_deadline" { @@ -33,8 +40,16 @@ fn main() { std::io::stdout().flush().unwrap(); let ping = read(&mut input); assert!(ping.contains("server-ping") && ping.contains("result")); - println!(r#"{{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}}"#); - reply(if args[1] == "mcp_wrong_id" { "wrong-request" } else { id(&request) }, r#"{"content":[{"type":"text","text":"native MCP success"}]}"#); + if args[1] != "mcp_idle_change" { println!(r#"{{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}}"#); } + reply(if args[1] == "mcp_wrong_id" { "wrong-request" } else { id(&request) }, if args[1] == "mcp_bad_result" { r#"{"content":[],"structuredContent":{"ok":"wrong type"}}"# } else { r#"{"content":[{"type":"text","text":"native MCP success"}],"structuredContent":{"ok":true}}"# }); + if args[1] == "mcp_idle_change" { + std::thread::sleep(Duration::from_millis(50)); + println!(r#"{{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}}"#); + std::io::stdout().flush().unwrap(); + let stale = read(&mut input); + if !stale.is_empty() { reply(id(&stale), r#"{"content":[],"structuredContent":{"ok":true}}"#); } + return; + } // MCP server remains alive between calls until Host closes stdin. while !read(&mut input).is_empty() {} return;