feat(mcp): 验证有界工具目录并使陈旧契约失效

This commit is contained in:
2026-09-09 01:00:02 +08:00
parent 7c7a637e38
commit 6373829243
6 changed files with 578 additions and 21 deletions
@@ -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 路由;还需完成全部沙箱资源/并发攻击验证和其他生产化验收,第三方执行能力保持禁用。
+47 -3
View File
@@ -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"
);
}
+90 -15
View File
@@ -92,6 +92,7 @@ pub struct Session<'a, 'p> {
tools: bool,
failed: bool,
tools_changed: bool,
catalog: Option<crate::extension_mcp_tools::Catalog>,
}
impl<'a, 'p> Session<'a, 'p> {
pub fn new(process: &'a Running<'p>, io: HostIo) -> Result<Self> {
@@ -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<String> {
@@ -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<Value> {
pub fn refresh_tools(
&mut self,
cancel: &AtomicBool,
) -> Result<Vec<crate::extension_mcp_tools::Description>> {
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<Value> {
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<Value> {
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<bool> {
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)) {
@@ -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<jsonschema::Validator> {
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<String>,
pub description: Option<String>,
pub input_schema: Value,
pub output_schema: Option<Value>,
}
pub struct Tool {
description: Description,
input: jsonschema::Validator,
output: Option<jsonschema::Validator>,
}
impl Tool {
fn parse(value: &Value) -> Result<Self> {
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<Option<String>> {
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<String, Arc<Tool>>,
}
impl Catalog {
pub fn discover(mut fetch: impl FnMut(Option<&str>) -> Result<Value>) -> Result<Self> {
let mut tools = BTreeMap::new();
let mut seen = BTreeSet::new();
let mut cursor: Option<String> = 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<Description> {
self.tools
.values()
.map(|tool| tool.description.clone())
.collect()
}
pub fn tool(&self, name: &str) -> Result<Arc<Tool>> {
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"
);
}
}
+3
View File
@@ -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;
+18 -3
View File
@@ -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;