feat(mcp): 将单次调用审阅绑定到已验证实例

This commit is contained in:
2026-09-09 01:10:55 +08:00
parent 6373829243
commit 96778c02f3
8 changed files with 421 additions and 12 deletions
@@ -507,3 +507,15 @@ Core 的独立数据目录目前不等于已授权 Vault。Python 旧笔记写
- 单元测试覆盖分页、重复、循环、总量/页数限制,必填项/类型/额外参数、结构化结果、无效 base64、所有引用类型、未知方言/不支持正则/任务要求与过深输入。真实 AppContainer 测试新增两页目录、发现前/未知名/无效参数拒绝、错误 structuredContent、调用中及空闲时工具变更;全部经实际原生 Session/Pump 运行。 - 单元测试覆盖分页、重复、循环、总量/页数限制,必填项/类型/额外参数、结构化结果、无效 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)。 - 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 路由;还需完成全部沙箱资源/并发攻击验证和其他生产化验收,第三方执行能力保持禁用。 - 工具发现和校验不等于用户批准执行。生产入口仍需完成实际安装实例注册、来源/活动版本/在线信任与工具目录摘要绑定、用户调用授权及 Core 路由;还需完成全部沙箱资源/并发攻击验证和其他生产化验收,第三方执行能力保持禁用。
## 增量:实例身份与一次性工具调用预览凭据
- Context.prepare 在现有许可、入口和上下文验证完成后生成独立实例 UUID,以及覆盖完整 Claims 的执行摘要;用于摘要的临时序列化字节由 Zeroizing 清理。公开身份预览只包含执行类型、来源/命名空间/包/版本、Vault、权限集和摘要,不包含环境秘密或原始启动参数。
- PreparedLaunch → LeasedSuspended → Running 持续携带该身份,带租约恢复方法收为 crate 内部。Session.new 要求运行实例具有这条准备链产生的身份,普通低层 resume 的受控测试进程不能直接进入该 MCP 会话入口。
- 新增内存 Gate,生成包含真实工具描述、冻结参数、实例身份和工具契约摘要的 Review;最多 64 条待确认预览、合计 2 MiB 待确认参数,120 秒单调时钟有效期。确认会消费预览 ID,不能重复确认;过期和目录失效清理待确认项,已确认对象也绑定原到期时间。
- 正式 Session.call_tool 现在只接受 ApprovedCall。该类型字段私有、不可 Clone、不可序列化/反序列化,工具名及参数不能在确认后另行替换;调用消费该对象并核对实例、目录代次和当前契约摘要,再进入参数/结果校验、运行租约及期限链路。按名称/参数直接调用的方法收为私有,只有 cfg(test) 辅助方法能模拟本测试用户确认。
- 目录刷新开始、已观察到工具变更及会话失败均作废旧预览和目录代次;即使工具名字和 schema 恢复成原值,旧已确认对象也不能重新使用。此处是 Host 观察和持有的目录版本绑定,不是对服务端执行语义永不变化或核验到发送间“零竞态”的证明。
- 单元测试覆盖预览对象被改写后实际参数仍冻结、重复确认、跨实例、目录代次、确认前/后过期、条数/字节上限及清理后复用。真实 AppContainer 测试核对预览来源/包/Vault,修改展示副本不会改变实际请求,旧目录凭据被拒绝;新增确认后锁定保险库导致 CREDENTIALS_LOCKED,重新解锁也不能恢复已失效会话。
- 64 项扩展回归通过,5 项 ignored 为三个父测试驱动 Job 辅助入口及两个显式长时验收,本轮未重跑长时项。desktop 全目标 Clippy -D warnings 通过。日志 `.build/extension-call-binding-tests.log``.build/extension-call-binding-clippy.log`
- 当前 confirm_call 仍只是 Host 内部组件接口,未登记为 renderer/Core 命令,也没有自动批准策略或真实用户确认 UI。完整产品入口必须通过经过认证的 Host 路由确认用户批准,再与活动安装/在线信任、实例注册和 Core 路由组合;全部沙箱资源与其他生产化验收仍未完成,第三方执行能力保持禁用。
@@ -0,0 +1,278 @@
//! Host-memory call reviews. The UI/registry must establish actual user consent
//! before confirm; no renderer command or automatic-consent policy is added here.
use crate::{
extension_mcp_tools::{Description, Tool},
extension_permit::{Claims, ExecutionKind},
workspace::{HostError, Result},
};
use serde::Serialize;
use serde_json::Value;
use sha2::{Digest, Sha256};
use std::{
collections::{BTreeMap, BTreeSet},
time::{Duration, Instant},
};
use zeroize::Zeroizing;
#[derive(Clone, Serialize)]
pub struct Identity {
instance_id: String,
kind: ExecutionKind,
source: String,
namespace: String,
package_id: String,
version: String,
vault_id: String,
permissions: BTreeSet<String>,
execution_digest: String,
}
impl Identity {
/// Only called after launch permit/entry/context validation.
pub(crate) fn from_claims(claims: &Claims) -> Result<Self> {
let bytes = Zeroizing::new(
serde_json::to_vec(claims)
.map_err(|_| HostError::new("EXTENSION_CALL_BINDING_INVALID"))?,
);
let mut hash = Sha256::new();
hash.update(b"OpenNexus execution identity v1\0");
hash.update(bytes.as_slice());
Ok(Self {
instance_id: uuid::Uuid::new_v4().to_string(),
kind: claims.kind,
source: claims.source.clone(),
namespace: claims.namespace.clone(),
package_id: claims.package_id.clone(),
version: claims.version.clone(),
vault_id: claims.vault_id.clone(),
permissions: claims.permissions.clone(),
execution_digest: format!("{:x}", hash.finalize()),
})
}
}
#[derive(Serialize)]
pub struct Review {
pub review_id: String,
pub identity: Identity,
pub tool: Description,
pub arguments: Value,
pub contract_digest: String,
pub valid_for_seconds: u64,
}
struct Pending {
name: String,
arguments: Value,
contract_digest: String,
expires: Instant,
bytes: usize,
}
/// An in-process, non-cloneable, non-serializable, single-consumption capability.
/// Tool name and arguments cannot be replaced after review confirmation.
pub struct ApprovedCall {
instance: String,
epoch: String,
call: Pending,
}
pub(crate) struct Invocation {
pub name: String,
pub arguments: Value,
pub contract_digest: String,
}
pub(crate) struct Gate {
identity: Identity,
epoch: String,
pending: BTreeMap<String, Pending>,
}
impl Gate {
pub(crate) fn new(identity: Identity) -> Self {
Self {
identity,
epoch: uuid::Uuid::new_v4().to_string(),
pending: BTreeMap::new(),
}
}
pub(crate) fn invalidate(&mut self) {
self.epoch = uuid::Uuid::new_v4().to_string();
self.pending.clear();
}
pub(crate) fn review(&mut self, tool: &Tool, arguments: Value) -> Result<Review> {
tool.validate_arguments(&arguments)?;
self.pending
.retain(|_, pending| pending.expires > Instant::now());
let bytes = serde_json::to_vec(&arguments)
.map_err(|_| HostError::new("EXTENSION_CALL_REVIEW_INVALID"))?
.len();
if self.pending.len() >= 64
|| self.pending.values().map(|p| p.bytes).sum::<usize>() + bytes > 2 * 1024 * 1024
{
return Err(HostError::new("EXTENSION_CALL_REVIEW_LIMIT"));
}
let tool = tool.description();
let contract_digest = contract_digest(&tool)?;
let review_id = uuid::Uuid::new_v4().to_string();
self.pending.insert(
review_id.clone(),
Pending {
name: tool.name.clone(),
arguments: arguments.clone(),
contract_digest: contract_digest.clone(),
expires: Instant::now() + Duration::from_secs(120),
bytes,
},
);
Ok(Review {
review_id,
identity: self.identity.clone(),
tool,
arguments,
contract_digest,
valid_for_seconds: 120,
})
}
/// The authenticated Host approval route must verify user consent first.
pub(crate) fn confirm(&mut self, review_id: &str) -> Result<ApprovedCall> {
let call = self
.pending
.remove(review_id)
.ok_or_else(|| HostError::new("EXTENSION_CALL_REVIEW_UNKNOWN"))?;
if call.expires <= Instant::now() {
return Err(HostError::new("EXTENSION_CALL_REVIEW_EXPIRED"));
}
Ok(ApprovedCall {
instance: self.identity.instance_id.clone(),
epoch: self.epoch.clone(),
call,
})
}
pub(crate) fn consume(&self, approved: ApprovedCall) -> Result<Invocation> {
if approved.instance != self.identity.instance_id {
return Err(HostError::new("EXTENSION_CALL_INSTANCE_MISMATCH"));
}
if approved.epoch != self.epoch {
return Err(HostError::new("EXTENSION_CALL_CATALOG_CHANGED"));
}
if approved.call.expires <= Instant::now() {
return Err(HostError::new("EXTENSION_CALL_REVIEW_EXPIRED"));
}
Ok(Invocation {
name: approved.call.name,
arguments: approved.call.arguments,
contract_digest: approved.call.contract_digest,
})
}
}
pub(crate) fn contract_digest(tool: &Description) -> Result<String> {
let mut hash = Sha256::new();
hash.update(b"OpenNexus MCP tool contract v1\0");
hash.update(
serde_json::to_vec(tool).map_err(|_| HostError::new("EXTENSION_CALL_REVIEW_INVALID"))?,
);
Ok(format!("{:x}", hash.finalize()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::extension_mcp_tools::Catalog;
use serde_json::json;
fn identity() -> Identity {
Identity {
instance_id: uuid::Uuid::new_v4().to_string(),
kind: ExecutionKind::Mcp,
source: "https://catalog.example/".into(),
namespace: "examples".into(),
package_id: "echo".into(),
version: "1.0.0".into(),
vault_id: uuid::Uuid::new_v4().to_string(),
permissions: BTreeSet::new(),
execution_digest: "a".repeat(64),
}
}
fn tool() -> std::sync::Arc<Tool> {
Catalog::discover(|_| {
Ok(json!({"tools":[{"name":"echo","inputSchema":{"type":"object"}}]}))
})
.unwrap()
.tool("echo")
.unwrap()
}
#[test]
fn review_freezes_arguments_and_is_single_use_instance_epoch_and_expiry_bound() {
let mut gate = Gate::new(identity());
let tool = tool();
let mut review = gate.review(&tool, json!({"value":"original"})).unwrap();
review.arguments = json!({"value":"tampered"});
review.tool.name = "tampered".into();
let approved = gate.confirm(&review.review_id).unwrap();
assert_eq!(
gate.confirm(&review.review_id).err().unwrap().code,
"EXTENSION_CALL_REVIEW_UNKNOWN"
);
let call = gate.consume(approved).unwrap();
assert_eq!(call.name, "echo");
assert_eq!(call.arguments, json!({"value":"original"}));
assert_eq!(
call.contract_digest,
contract_digest(&tool.description()).unwrap()
);
let review = gate.review(&tool, json!({})).unwrap();
let approved = gate.confirm(&review.review_id).unwrap();
assert_eq!(
Gate::new(identity()).consume(approved).err().unwrap().code,
"EXTENSION_CALL_INSTANCE_MISMATCH"
);
let review = gate.review(&tool, json!({})).unwrap();
let approved = gate.confirm(&review.review_id).unwrap();
let pending = gate.review(&tool, json!({})).unwrap();
gate.invalidate();
assert_eq!(
gate.consume(approved).err().unwrap().code,
"EXTENSION_CALL_CATALOG_CHANGED"
);
assert_eq!(
gate.confirm(&pending.review_id).err().unwrap().code,
"EXTENSION_CALL_REVIEW_UNKNOWN"
);
let review = gate.review(&tool, json!({})).unwrap();
gate.pending.get_mut(&review.review_id).unwrap().expires = Instant::now();
assert_eq!(
gate.confirm(&review.review_id).err().unwrap().code,
"EXTENSION_CALL_REVIEW_EXPIRED"
);
let review = gate.review(&tool, json!({})).unwrap();
let mut approved = gate.confirm(&review.review_id).unwrap();
approved.call.expires = Instant::now();
assert_eq!(
gate.consume(approved).err().unwrap().code,
"EXTENSION_CALL_REVIEW_EXPIRED"
);
}
#[test]
fn pending_review_count_and_bytes_are_bounded_and_invalidated_slots_are_reusable() {
let tool = tool();
let mut gate = Gate::new(identity());
for _ in 0..64 {
gate.review(&tool, json!({})).unwrap();
}
assert_eq!(
gate.review(&tool, json!({})).err().unwrap().code,
"EXTENSION_CALL_REVIEW_LIMIT"
);
gate.invalidate();
for _ in 0..10 {
gate.review(&tool, json!({"value":"x".repeat(200_000)}))
.unwrap();
}
assert_eq!(
gate.review(&tool, json!({"value":"x".repeat(200_000)}))
.err()
.unwrap()
.code,
"EXTENSION_CALL_REVIEW_LIMIT"
);
for value in gate.pending.values_mut() {
value.expires = Instant::now();
}
gate.review(&tool, json!({"value":"x".repeat(200_000)}))
.unwrap();
assert_eq!(gate.pending.len(), 1);
}
}
+48 -8
View File
@@ -874,6 +874,7 @@ mod tests {
"mcp_pages", "mcp_pages",
"mcp_bad_result", "mcp_bad_result",
"mcp_idle_change", "mcp_idle_change",
"mcp_review_lock",
]; ];
if _mcp_deadline { if _mcp_deadline {
mcp_modes.push("mcp_deadline"); mcp_modes.push("mcp_deadline");
@@ -898,7 +899,7 @@ mod tests {
let cancel = Arc::new(AtomicBool::new(false)); let cancel = Arc::new(AtomicBool::new(false));
assert_eq!( assert_eq!(
session session
.call_tool("echo", serde_json::json!({}), &cancel) .test_call_tool("echo", serde_json::json!({}), &cancel)
.unwrap_err() .unwrap_err()
.code, .code,
"EXTENSION_MCP_NOT_INITIALIZED" "EXTENSION_MCP_NOT_INITIALIZED"
@@ -915,7 +916,7 @@ mod tests {
); );
assert_eq!( assert_eq!(
session session
.call_tool("echo", serde_json::json!({}), &cancel) .test_call_tool("echo", serde_json::json!({}), &cancel)
.unwrap_err() .unwrap_err()
.code, .code,
"EXTENSION_MCP_CATALOG_REQUIRED" "EXTENSION_MCP_CATALOG_REQUIRED"
@@ -923,14 +924,14 @@ mod tests {
assert_eq!(session.refresh_tools(&cancel).unwrap()[0].name, "echo"); assert_eq!(session.refresh_tools(&cancel).unwrap()[0].name, "echo");
assert_eq!( assert_eq!(
session session
.call_tool("missing", serde_json::json!({}), &cancel) .test_call_tool("missing", serde_json::json!({}), &cancel)
.unwrap_err() .unwrap_err()
.code, .code,
"EXTENSION_MCP_TOOL_NOT_FOUND" "EXTENSION_MCP_TOOL_NOT_FOUND"
); );
assert_eq!( assert_eq!(
session session
.call_tool("echo", serde_json::json!({"unexpected":1}), &cancel) .test_call_tool("echo", serde_json::json!({"unexpected":1}), &cancel)
.unwrap_err() .unwrap_err()
.code, .code,
"EXTENSION_MCP_ARGUMENTS_INVALID" "EXTENSION_MCP_ARGUMENTS_INVALID"
@@ -944,9 +945,42 @@ mod tests {
} else { } else {
None None
}; };
let mut earlier = None;
if mode == "mcp" {
let review = session.review_call("echo", serde_json::json!({})).unwrap();
let identity = serde_json::to_value(&review.identity).unwrap();
assert_eq!(identity["package_id"], mcp.package_id);
assert_eq!(identity["vault_id"], mcp.vault_id);
assert_eq!(identity["source"], mcp.source);
assert_eq!(identity["execution_digest"].as_str().unwrap().len(), 64);
earlier = Some(session.confirm_call(&review.review_id).unwrap());
}
let tool_started = std::time::Instant::now(); let tool_started = std::time::Instant::now();
let result = session.call_tool("echo", serde_json::json!({}), &cancel); let result = if mode == "mcp" {
let mut review =
session.review_call("echo", serde_json::json!({})).unwrap();
review.arguments = serde_json::json!({"unexpected":1});
let approved = session.confirm_call(&review.review_id).unwrap();
session.call_tool(approved, &cancel)
} else if mode == "mcp_review_lock" {
let review = session.review_call("echo", serde_json::json!({})).unwrap();
let approved = session.confirm_call(&review.review_id).unwrap();
broker.lock();
session.call_tool(approved, &cancel)
} else {
session.test_call_tool("echo", serde_json::json!({}), &cancel)
};
match mode { match mode {
"mcp_review_lock" => {
assert_eq!(result.unwrap_err().code, "CREDENTIALS_LOCKED");
broker
.unlock(Zeroizing::new(b"native fixture passphrase".to_vec()))
.unwrap();
assert_eq!(
session.refresh_tools(&cancel).err().unwrap().code,
"EXTENSION_MCP_SESSION_FAILED"
);
}
"mcp_bad_result" => assert_eq!( "mcp_bad_result" => assert_eq!(
result.unwrap_err().code, result.unwrap_err().code,
"EXTENSION_MCP_TOOL_RESULT_INVALID" "EXTENSION_MCP_TOOL_RESULT_INVALID"
@@ -956,7 +990,7 @@ mod tests {
std::thread::sleep(std::time::Duration::from_millis(200)); std::thread::sleep(std::time::Duration::from_millis(200));
assert_eq!( assert_eq!(
session session
.call_tool("echo", serde_json::json!({}), &cancel) .test_call_tool("echo", serde_json::json!({}), &cancel)
.unwrap_err() .unwrap_err()
.code, .code,
"EXTENSION_MCP_CATALOG_REQUIRED" "EXTENSION_MCP_CATALOG_REQUIRED"
@@ -1005,7 +1039,7 @@ mod tests {
assert_eq!(result.unwrap_err().code, "EXTENSION_MCP_REMOTE_ERROR"); assert_eq!(result.unwrap_err().code, "EXTENSION_MCP_REMOTE_ERROR");
assert_eq!( assert_eq!(
session session
.call_tool("echo", serde_json::json!({}), &cancel) .test_call_tool("echo", serde_json::json!({}), &cancel)
.unwrap()["content"][0]["text"], .unwrap()["content"][0]["text"],
"native MCP success" "native MCP success"
); );
@@ -1014,9 +1048,15 @@ mod tests {
assert_eq!(result.unwrap()["content"][0]["text"], "native MCP success"); assert_eq!(result.unwrap()["content"][0]["text"], "native MCP success");
assert!(session.take_tools_changed()); assert!(session.take_tools_changed());
assert!(!session.take_tools_changed()); assert!(!session.take_tools_changed());
if let Some(approved) = earlier {
assert_eq!(
session.call_tool(approved, &cancel).unwrap_err().code,
"EXTENSION_CALL_CATALOG_CHANGED"
);
}
assert_eq!( assert_eq!(
session session
.call_tool("echo", serde_json::json!({}), &cancel) .test_call_tool("echo", serde_json::json!({}), &cancel)
.unwrap_err() .unwrap_err()
.code, .code,
"EXTENSION_MCP_CATALOG_REQUIRED" "EXTENSION_MCP_CATALOG_REQUIRED"
@@ -22,6 +22,7 @@ pub struct Context<'a> {
pub struct PreparedLaunch { pub struct PreparedLaunch {
data: LaunchData, data: LaunchData,
lease: crate::extension_permit::Lease, lease: crate::extension_permit::Lease,
identity: crate::extension_call_authorization::Identity,
path: std::path::PathBuf, path: std::path::PathBuf,
entry: String, entry: String,
tree: String, tree: String,
@@ -29,6 +30,7 @@ pub struct PreparedLaunch {
pub struct LeasedSuspended<'a> { pub struct LeasedSuspended<'a> {
process: crate::extension_process::Suspended<'a>, process: crate::extension_process::Suspended<'a>,
lease: crate::extension_permit::Lease, lease: crate::extension_permit::Lease,
identity: crate::extension_call_authorization::Identity,
} }
impl PreparedLaunch { impl PreparedLaunch {
pub fn create_suspended<'a>( pub fn create_suspended<'a>(
@@ -48,6 +50,7 @@ impl PreparedLaunch {
Ok(LeasedSuspended { Ok(LeasedSuspended {
process, process,
lease: self.lease, lease: self.lease,
identity: self.identity,
}) })
} }
pub fn create_suspended_with_stdio<'a>( pub fn create_suspended_with_stdio<'a>(
@@ -70,6 +73,7 @@ impl PreparedLaunch {
LeasedSuspended { LeasedSuspended {
process, process,
lease: self.lease, lease: self.lease,
identity: self.identity,
}, },
io, io,
)) ))
@@ -80,7 +84,7 @@ impl<'a> LeasedSuspended<'a> {
/// Live trust, active installation, broker and all sandbox resource policy /// Live trust, active installation, broker and all sandbox resource policy
/// requirements must also hold. A lease does not establish those conditions. /// requirements must also hold. A lease does not establish those conditions.
pub unsafe fn resume(self) -> Result<crate::extension_process::Running<'a>> { pub unsafe fn resume(self) -> Result<crate::extension_process::Running<'a>> {
unsafe { self.process.resume_with_lease(self.lease) } unsafe { self.process.resume_with_lease(self.lease, self.identity) }
} }
} }
struct EnvironmentValues(BTreeMap<String, String>); struct EnvironmentValues(BTreeMap<String, String>);
@@ -153,6 +157,7 @@ impl Context<'_> {
Ok(PreparedLaunch { Ok(PreparedLaunch {
data, data,
lease, lease,
identity: crate::extension_call_authorization::Identity::from_claims(claims)?,
path: entry.path().to_owned(), path: entry.path().to_owned(),
entry: entry.relative_name().to_owned(), entry: entry.relative_name().to_owned(),
tree: entry.tree_sha256().to_owned(), tree: entry.tree_sha256().to_owned(),
+57 -2
View File
@@ -1,5 +1,5 @@
//! Serial MCP session over an already-authorized native instance. Package trust, //! Serial MCP session over an already-authorized native instance. The Host
//! tool consent/schema validation and registry routing remain Host responsibilities. //! approval route still must establish user consent and current installation/trust.
use crate::{ use crate::{
extension_io::{Event, Pump}, extension_io::{Event, Pump},
extension_process::Running, extension_process::Running,
@@ -93,6 +93,7 @@ pub struct Session<'a, 'p> {
failed: bool, failed: bool,
tools_changed: bool, tools_changed: bool,
catalog: Option<crate::extension_mcp_tools::Catalog>, catalog: Option<crate::extension_mcp_tools::Catalog>,
calls: crate::extension_call_authorization::Gate,
} }
impl<'a, 'p> Session<'a, 'p> { impl<'a, 'p> Session<'a, 'p> {
pub fn new(process: &'a Running<'p>, io: HostIo) -> Result<Self> { pub fn new(process: &'a Running<'p>, io: HostIo) -> Result<Self> {
@@ -105,6 +106,7 @@ impl<'a, 'p> Session<'a, 'p> {
failed: false, failed: false,
tools_changed: false, tools_changed: false,
catalog: None, catalog: None,
calls: crate::extension_call_authorization::Gate::new(process.call_identity()?),
}) })
} }
pub fn initialize(&mut self, cancel: &AtomicBool) -> Result<String> { pub fn initialize(&mut self, cancel: &AtomicBool) -> Result<String> {
@@ -149,6 +151,7 @@ impl<'a, 'p> Session<'a, 'p> {
self.require_tools()?; self.require_tools()?;
self.drain_pending()?; self.drain_pending()?;
self.catalog = None; self.catalog = None;
self.calls.invalidate();
self.tools_changed = false; self.tools_changed = false;
let started = Instant::now(); let started = Instant::now();
let catalog = crate::extension_mcp_tools::Catalog::discover(|cursor| { let catalog = crate::extension_mcp_tools::Catalog::discover(|cursor| {
@@ -201,12 +204,62 @@ impl<'a, 'p> Session<'a, 'p> {
} }
Ok(value) Ok(value)
} }
pub fn review_call(
&mut self,
name: &str,
arguments: Value,
) -> Result<crate::extension_call_authorization::Review> {
self.require_tools()?;
self.drain_pending()?;
let tool = self
.catalog
.as_ref()
.ok_or_else(|| HostError::new("EXTENSION_MCP_CATALOG_REQUIRED"))?
.tool(name)?;
self.calls.review(&tool, arguments)
}
/// Only invoke from an authenticated Host route after the user approved this
/// exact review. This method is not registered as a renderer/Core command.
pub fn confirm_call(
&mut self,
review_id: &str,
) -> Result<crate::extension_call_authorization::ApprovedCall> {
self.require_tools()?;
self.drain_pending()?;
self.calls.confirm(review_id)
}
pub fn call_tool( pub fn call_tool(
&mut self,
approved: crate::extension_call_authorization::ApprovedCall,
cancel: &AtomicBool,
) -> Result<Value> {
self.require_tools()?;
self.drain_pending()?;
let call = self.calls.consume(approved)?;
let tool = self
.catalog
.as_ref()
.ok_or_else(|| HostError::new("EXTENSION_MCP_CATALOG_REQUIRED"))?
.tool(&call.name)?;
if crate::extension_call_authorization::contract_digest(&tool.description())?
!= call.contract_digest
{
return Err(HostError::new("EXTENSION_CALL_CATALOG_CHANGED"));
}
self.invoke_tool(&call.name, call.arguments, cancel)
}
#[cfg(test)]
pub(crate) fn test_call_tool(
&mut self, &mut self,
name: &str, name: &str,
arguments: Value, arguments: Value,
cancel: &AtomicBool, cancel: &AtomicBool,
) -> Result<Value> { ) -> Result<Value> {
let review = self.review_call(name, arguments)?;
let approved = self.confirm_call(&review.review_id)?;
self.call_tool(approved, cancel)
}
fn invoke_tool(&mut self, name: &str, arguments: Value, cancel: &AtomicBool) -> Result<Value> {
self.require_tools()?; self.require_tools()?;
self.drain_pending()?; self.drain_pending()?;
if name.is_empty() if name.is_empty()
@@ -256,6 +309,7 @@ impl<'a, 'p> Session<'a, 'p> {
} else if method == "notifications/tools/list_changed" { } else if method == "notifications/tools/list_changed" {
self.tools_changed = true; self.tools_changed = true;
self.catalog = None; self.catalog = None;
self.calls.invalidate();
} }
Ok(true) Ok(true)
} }
@@ -294,6 +348,7 @@ impl<'a, 'p> Session<'a, 'p> {
} }
fn abort(&mut self) { fn abort(&mut self) {
self.failed = true; self.failed = true;
self.calls.invalidate();
let _ = self.process.terminate(); let _ = self.process.terminate();
} }
fn request( fn request(
@@ -77,6 +77,9 @@ pub struct Tool {
output: Option<jsonschema::Validator>, output: Option<jsonschema::Validator>,
} }
impl Tool { impl Tool {
pub(crate) fn description(&self) -> Description {
self.description.clone()
}
fn parse(value: &Value) -> Result<Self> { fn parse(value: &Value) -> Result<Self> {
bounded(value, 192 * 1024, false)?; bounded(value, 192 * 1024, false)?;
let name = value["name"] let name = value["name"]
+14 -1
View File
@@ -94,6 +94,8 @@ pub struct Running<'a> {
process: Process<'a>, process: Process<'a>,
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]
revocation: Option<crate::extension_revocation::Watch>, revocation: Option<crate::extension_revocation::Watch>,
#[cfg(feature = "desktop")]
identity: Option<crate::extension_call_authorization::Identity>,
} }
impl<'a> Suspended<'a> { impl<'a> Suspended<'a> {
/// Creates hidden, with no inherited handles and an explicit environment and /// Creates hidden, with no inherited handles and an explicit environment and
@@ -244,15 +246,18 @@ impl<'a> Suspended<'a> {
process: self.0, process: self.0,
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]
revocation: None, revocation: None,
#[cfg(feature = "desktop")]
identity: None,
}) })
} }
/// # Safety /// # Safety
/// The same complete resource/broker/trust preconditions as resume apply. /// The same complete resource/broker/trust preconditions as resume apply.
/// This additionally arms revocation monitoring before any instruction resumes. /// This additionally arms revocation monitoring before any instruction resumes.
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]
pub unsafe fn resume_with_lease( pub(crate) unsafe fn resume_with_lease(
self, self,
lease: crate::extension_permit::Lease, lease: crate::extension_permit::Lease,
identity: crate::extension_call_authorization::Identity,
) -> Result<Running<'a>> { ) -> Result<Running<'a>> {
let watch = crate::extension_revocation::Watch::arm(&self.0.job, lease)?; let watch = crate::extension_revocation::Watch::arm(&self.0.job, lease)?;
watch.check()?; watch.check()?;
@@ -262,12 +267,20 @@ impl<'a> Suspended<'a> {
let running = Running { let running = Running {
process: self.0, process: self.0,
revocation: Some(watch), revocation: Some(watch),
identity: Some(identity),
}; };
running.check_authorization()?; running.check_authorization()?;
Ok(running) Ok(running)
} }
} }
impl Running<'_> { impl Running<'_> {
#[cfg(feature = "desktop")]
pub(crate) fn call_identity(&self) -> Result<crate::extension_call_authorization::Identity> {
self.check_authorization()?;
self.identity
.clone()
.ok_or_else(|| HostError::new("EXTENSION_CALL_BINDING_REQUIRED"))
}
#[cfg(feature = "desktop")] #[cfg(feature = "desktop")]
pub fn start_io( pub fn start_io(
&self, &self,
+3
View File
@@ -90,3 +90,6 @@ pub mod extension_mcp;
#[cfg(all(windows, feature = "desktop"))] #[cfg(all(windows, feature = "desktop"))]
pub mod extension_mcp_tools; pub mod extension_mcp_tools;
#[cfg(all(windows, feature = "desktop"))]
pub mod extension_call_authorization;