perspt_sdk/toolset/
external.rs1use super::entry::{ToolEntry, ToolOrigin};
20use super::footprint::FootprintSpec;
21use crate::capability::{Capability, EffectKind, RiskClass};
22use crate::error::{Result, SdkError};
23
24#[derive(Debug, Clone, PartialEq)]
26pub struct ExternalToolDeclaration {
27 pub name: String,
28 pub description: String,
29 pub effect: EffectKind,
30 pub risk: RiskClass,
31 pub schema: serde_json::Value,
32 pub footprint: Option<FootprintSpec>,
34}
35
36pub fn admit_external_tool(
42 server_id: &str,
43 declaration: ExternalToolDeclaration,
44 session_capabilities: &[Capability],
45) -> Result<ToolEntry> {
46 let (effect, risk, footprint) = match declaration.footprint {
48 Some(footprint) => (declaration.effect, declaration.risk, footprint),
49 None => (
50 EffectKind::RunShell,
51 RiskClass::High,
52 FootprintSpec::opaque(),
53 ),
54 };
55
56 let granted = session_capabilities
57 .iter()
58 .any(|c| c.effects.contains(&effect));
59 if !granted {
60 return Err(SdkError::Domain(format!(
61 "external server {server_id:?} declared tool {:?} with effect {effect:?}, \
62 which no session capability grants; registration never mints authority",
63 declaration.name
64 )));
65 }
66
67 let entry = ToolEntry {
68 name: declaration.name,
69 description: declaration.description,
70 discovery_summary: String::new(),
71 description_templates: None,
72 effect,
73 risk,
74 schema: declaration.schema,
75 footprint,
76 proposal_bindings: Vec::new(),
77 durable: true,
80 origin: ToolOrigin::External(server_id.to_string()),
81 hot: false,
84 };
85 entry.validate()?;
86 Ok(entry)
87}
88
89#[cfg(test)]
90mod tests {
91 use super::*;
92 use crate::capability::ActorId;
93 use crate::scheduler::Resource;
94 use crate::toolset::footprint::{AccessMode, ResourceSelector};
95
96 fn declaration(
97 effect: EffectKind,
98 footprint: Option<FootprintSpec>,
99 ) -> ExternalToolDeclaration {
100 ExternalToolDeclaration {
101 name: "company_search".into(),
102 description: "Search the internal index".into(),
103 effect,
104 risk: RiskClass::Low,
105 schema: serde_json::json!({
106 "type": "object",
107 "properties": {"query": {"type": "string"}},
108 "required": ["query"],
109 }),
110 footprint,
111 }
112 }
113
114 fn session_with(effects: Vec<EffectKind>) -> Vec<Capability> {
115 vec![Capability::new(ActorId::new("session"), effects)]
116 }
117
118 #[test]
119 fn a_declared_tool_within_session_authority_registers() {
120 let entry = admit_external_tool(
121 "mcp-1",
122 declaration(EffectKind::Search, Some(FootprintSpec::default())),
123 &session_with(vec![EffectKind::Search]),
124 )
125 .unwrap();
126 assert_eq!(entry.origin, ToolOrigin::External("mcp-1".into()));
127 assert_eq!(entry.effect, EffectKind::Search);
128 }
129
130 #[test]
131 fn registration_never_exceeds_session_authority() {
132 let result = admit_external_tool(
134 "mcp-1",
135 declaration(EffectKind::NetworkFetch, Some(FootprintSpec::opaque())),
136 &session_with(vec![EffectKind::Search]),
137 );
138 assert!(result.is_err(), "an external server cannot widen authority");
139 }
140
141 #[test]
142 fn an_undeclared_footprint_classifies_as_run_shell_and_opaque() {
143 let rejected = admit_external_tool(
147 "mcp-1",
148 declaration(EffectKind::Search, None),
149 &session_with(vec![EffectKind::Search]),
150 );
151 assert!(rejected.is_err());
152
153 let entry = admit_external_tool(
156 "mcp-1",
157 declaration(EffectKind::Search, None),
158 &session_with(vec![EffectKind::RunShell]),
159 )
160 .unwrap();
161 assert_eq!(entry.effect, EffectKind::RunShell);
162 let resolved = entry.footprint.resolve(&serde_json::json!({}), "p");
163 assert!(resolved.writes.contains(&Resource::OpaqueWorkspace));
164 }
165
166 #[test]
167 fn an_invalid_declared_footprint_is_rejected_at_admission() {
168 let bad_footprint = FootprintSpec::new(vec![ResourceSelector::PathArgument {
169 field: "undeclared_arg".into(),
170 access: AccessMode::Write,
171 }]);
172 let result = admit_external_tool(
173 "mcp-1",
174 declaration(EffectKind::Search, Some(bad_footprint)),
175 &session_with(vec![EffectKind::Search]),
176 );
177 assert!(result.is_err());
178 }
179}