1use std::collections::{BTreeMap, BTreeSet};
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5use sha2::{Digest, Sha256};
6
7use crate::tool_annotations::{SideEffectLevel, ToolAnnotations};
8
9use super::state::CompositionStateBinding;
10
11pub const BINDING_MANIFEST_SCHEMA_VERSION: u32 = 1;
12
13#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum BindingPolicyDisposition {
17 Allowed,
18 Gated,
19 Denied,
20}
21
22#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
24#[serde(default)]
25pub struct BindingPolicyStatus {
26 pub disposition: BindingPolicyDisposition,
27 pub reason: Option<String>,
28}
29
30impl Default for BindingPolicyStatus {
31 fn default() -> Self {
32 Self {
33 disposition: BindingPolicyDisposition::Allowed,
34 reason: None,
35 }
36 }
37}
38
39#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
41#[serde(default)]
42pub struct BindingManifestEntry {
43 pub name: String,
45 pub binding: String,
47 pub namespace: Option<String>,
48 pub description: Option<String>,
49 pub input_schema: Value,
50 pub output_schema: Option<Value>,
51 pub annotations: ToolAnnotations,
52 pub side_effect_level: SideEffectLevel,
53 pub capabilities: BTreeMap<String, Vec<String>>,
54 pub path_args: Vec<String>,
55 pub examples: Vec<Value>,
56 pub source: String,
59 pub deferred: bool,
60 pub policy: BindingPolicyStatus,
61 pub metadata: Value,
62}
63
64impl Default for BindingManifestEntry {
65 fn default() -> Self {
66 Self {
67 name: String::new(),
68 binding: String::new(),
69 namespace: None,
70 description: None,
71 input_schema: serde_json::json!({"type": "object"}),
72 output_schema: None,
73 annotations: ToolAnnotations::default(),
74 side_effect_level: SideEffectLevel::None,
75 capabilities: BTreeMap::new(),
76 path_args: Vec::new(),
77 examples: Vec::new(),
78 source: "harn".to_string(),
79 deferred: false,
80 policy: BindingPolicyStatus::default(),
81 metadata: Value::Object(serde_json::Map::new()),
82 }
83 }
84}
85
86#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
88#[serde(default)]
89pub struct BindingManifest {
90 pub schema_version: u32,
91 pub bindings: Vec<BindingManifestEntry>,
92 #[serde(skip_serializing_if = "Option::is_none")]
95 pub state: Option<CompositionStateBinding>,
96 pub side_effect_ceiling: SideEffectLevel,
97 pub metadata: Value,
98}
99
100impl Default for BindingManifest {
101 fn default() -> Self {
102 Self {
103 schema_version: BINDING_MANIFEST_SCHEMA_VERSION,
104 bindings: Vec::new(),
105 state: None,
106 side_effect_ceiling: SideEffectLevel::ReadOnly,
107 metadata: Value::Object(serde_json::Map::new()),
108 }
109 }
110}
111
112impl BindingManifest {
113 pub fn new(mut bindings: Vec<BindingManifestEntry>, ceiling: SideEffectLevel) -> Self {
114 bindings.sort_by(|a, b| a.binding.cmp(&b.binding).then(a.name.cmp(&b.name)));
115 Self {
116 bindings,
117 side_effect_ceiling: ceiling,
118 ..Self::default()
119 }
120 }
121
122 pub fn to_value(&self) -> Value {
123 serde_json::to_value(self).unwrap_or_else(|_| serde_json::json!({"bindings": []}))
124 }
125
126 pub fn to_compact_value(&self) -> Value {
127 let mut compact = serde_json::Map::from_iter([
128 (
129 "schema_version".to_string(),
130 Value::Number(self.schema_version.into()),
131 ),
132 (
133 "side_effect_ceiling".to_string(),
134 serde_json::json!(self.side_effect_ceiling),
135 ),
136 (
137 "bindings".to_string(),
138 Value::Array(
139 self.bindings
140 .iter()
141 .map(|binding| {
142 serde_json::json!({
143 "name": binding.name,
144 "binding": binding.binding,
145 "namespace": binding.namespace,
146 "description": binding.description,
147 "side_effect_level": binding.side_effect_level,
148 "policy": binding.policy,
149 "source": binding.source,
150 "deferred": binding.deferred,
151 "examples": binding.examples,
152 })
153 })
154 .collect(),
155 ),
156 ),
157 ]);
158 if let Some(state) = &self.state {
159 compact.insert(
160 "state".to_string(),
161 serde_json::to_value(state).unwrap_or(Value::Null),
162 );
163 }
164 Value::Object(compact)
165 }
166
167 pub fn hash(&self) -> Result<String, serde_json::Error> {
168 binding_manifest_hash(&self.to_value())
169 }
170
171 pub fn find_by_binding(&self, binding: &str) -> Option<&BindingManifestEntry> {
172 self.bindings.iter().find(|entry| entry.binding == binding)
173 }
174
175 pub fn find_by_name(&self, name: &str) -> Option<&BindingManifestEntry> {
176 self.bindings.iter().find(|entry| entry.name == name)
177 }
178}
179
180pub fn binding_manifest_hash(manifest: &Value) -> Result<String, serde_json::Error> {
183 let canonical = serde_json::to_vec(manifest)?;
184 let mut hasher = Sha256::new();
185 hasher.update(b"harn.composition.binding_manifest.v1\0");
186 hasher.update(&canonical);
187 Ok(format!("sha256:{}", hex::encode(hasher.finalize())))
188}
189
190#[derive(Clone, Debug, Eq, PartialEq)]
191pub struct BindingManifestOptions {
192 pub side_effect_ceiling: SideEffectLevel,
193 pub include_denied: bool,
194 pub denied_tools: BTreeSet<String>,
195 pub gated_tools: BTreeSet<String>,
196 pub state: Option<CompositionStateBinding>,
197}
198
199impl Default for BindingManifestOptions {
200 fn default() -> Self {
201 Self {
202 side_effect_ceiling: SideEffectLevel::ReadOnly,
203 include_denied: false,
204 denied_tools: BTreeSet::new(),
205 gated_tools: BTreeSet::new(),
206 state: None,
207 }
208 }
209}
210
211pub fn binding_manifest_from_tool_surface(
214 tools: &Value,
215 options: BindingManifestOptions,
216) -> BindingManifest {
217 let mut used_bindings = BTreeSet::new();
218 if options.state.is_some() {
219 used_bindings.insert("state".to_string());
220 }
221 let annotations_by_name = crate::tool_surface::tool_annotations_from_spec(tools);
222 let mut entries = Vec::new();
223 for tool in tool_surface_entries(tools) {
224 let Some(name) = tool
225 .get("name")
226 .and_then(Value::as_str)
227 .filter(|s| !s.is_empty())
228 else {
229 continue;
230 };
231 let annotations = tool
232 .get("annotations")
233 .cloned()
234 .and_then(|value| serde_json::from_value::<ToolAnnotations>(value).ok())
235 .or_else(|| annotations_by_name.get(name).cloned())
236 .unwrap_or_default();
237 let side_effect_level = annotations.side_effect_level;
238 let mut policy = BindingPolicyStatus::default();
239 if options.denied_tools.contains(name) {
240 policy.disposition = BindingPolicyDisposition::Denied;
241 policy.reason = Some("denied by active tool policy".to_string());
242 } else if side_effect_level.rank() > options.side_effect_ceiling.rank() {
243 policy.disposition = BindingPolicyDisposition::Denied;
244 policy.reason = Some(format!(
245 "requires side-effect level '{}' above composition ceiling '{}'",
246 side_effect_level.as_str(),
247 options.side_effect_ceiling.as_str()
248 ));
249 } else if options.gated_tools.contains(name) {
250 policy.disposition = BindingPolicyDisposition::Gated;
251 policy.reason = Some("requires host approval before dispatch".to_string());
252 }
253 if !options.include_denied && policy.disposition == BindingPolicyDisposition::Denied {
254 continue;
255 }
256 let binding = unique_binding_identifier(name, &mut used_bindings);
257 let source = binding_source(&tool);
258 let deferred = tool
259 .get("defer_loading")
260 .and_then(Value::as_bool)
261 .or_else(|| {
262 tool.get("function")
263 .and_then(|function| function.get("defer_loading"))
264 .and_then(Value::as_bool)
265 })
266 .unwrap_or(source == "deferred");
267 let input_schema = tool
268 .get("inputSchema")
269 .or_else(|| tool.get("input_schema"))
270 .or_else(|| tool.get("parameters"))
271 .or_else(|| tool.get("function").and_then(|f| f.get("parameters")))
272 .cloned()
273 .unwrap_or_else(|| serde_json::json!({"type": "object"}));
274 let output_schema = tool
275 .get("outputSchema")
276 .or_else(|| tool.get("output_schema"))
277 .or_else(|| tool.get("returns"))
278 .or_else(|| {
279 tool.get("function")
280 .and_then(|f| f.get("x-harn-output-schema"))
281 })
282 .cloned();
283 let examples = tool
284 .get("examples")
285 .and_then(Value::as_array)
286 .cloned()
287 .unwrap_or_default();
288 entries.push(BindingManifestEntry {
289 name: name.to_string(),
290 binding,
291 namespace: tool
292 .get("namespace")
293 .and_then(Value::as_str)
294 .map(ToOwned::to_owned),
295 description: tool
296 .get("description")
297 .or_else(|| tool.get("function").and_then(|f| f.get("description")))
298 .and_then(Value::as_str)
299 .filter(|s| !s.is_empty())
300 .map(ToOwned::to_owned),
301 input_schema,
302 output_schema,
303 side_effect_level,
304 capabilities: annotations.capabilities.clone(),
305 path_args: annotations.arg_schema.path_params.clone(),
306 annotations,
307 examples,
308 source,
309 deferred,
310 policy,
311 metadata: binding_metadata(&tool),
312 });
313 }
314 let mut manifest = BindingManifest::new(entries, options.side_effect_ceiling);
315 manifest.state = options.state;
316 manifest
317}
318
319fn tool_surface_entries(value: &Value) -> Vec<Value> {
320 match value {
321 Value::Array(items) => items.clone(),
322 Value::Object(map) => {
323 if let Some(Value::Array(items)) = map.get("tools") {
324 return items.clone();
325 }
326 if map.get("name").and_then(Value::as_str).is_some() {
327 return vec![value.clone()];
328 }
329 Vec::new()
330 }
331 _ => Vec::new(),
332 }
333}
334
335fn binding_source(tool: &Value) -> String {
336 if let Some(executor) = tool.get("executor").and_then(Value::as_str) {
337 return executor.to_string();
338 }
339 if tool.get("_mcp_server").is_some() || tool.get("mcp_server").is_some() {
340 return "mcp_server".to_string();
341 }
342 if tool.get("function").is_some() {
343 return "provider_native".to_string();
344 }
345 if tool
346 .get("defer_loading")
347 .and_then(Value::as_bool)
348 .unwrap_or(false)
349 {
350 return "deferred".to_string();
351 }
352 "harn".to_string()
353}
354
355fn binding_metadata(tool: &Value) -> Value {
356 let mut metadata = tool
357 .get("metadata")
358 .or_else(|| tool.get("_meta"))
359 .and_then(Value::as_object)
360 .cloned()
361 .unwrap_or_default();
362 for key in ["_mcp_server", "mcp_server", "_mcp_tool_name"] {
363 if let Some(value) = tool.get(key) {
364 metadata
365 .entry(key.to_string())
366 .or_insert_with(|| value.clone());
367 }
368 }
369 Value::Object(metadata)
370}
371
372fn unique_binding_identifier(name: &str, used: &mut BTreeSet<String>) -> String {
373 let base = sanitize_binding_identifier(name);
374 if used.insert(base.clone()) {
375 return base;
376 }
377 for index in 2.. {
378 let candidate = format!("{base}_{index}");
379 if used.insert(candidate.clone()) {
380 return candidate;
381 }
382 }
383 unreachable!("unbounded identifier suffix search")
384}
385
386fn sanitize_binding_identifier(name: &str) -> String {
387 let mut out = String::new();
388 for (idx, ch) in name.chars().enumerate() {
389 if ch == '_' || ch.is_ascii_alphanumeric() {
390 if idx == 0 && ch.is_ascii_digit() {
391 out.push_str("tool_");
392 }
393 out.push(ch);
394 } else {
395 out.push('_');
396 }
397 }
398 while out.contains("__") {
399 out = out.replace("__", "_");
400 }
401 let out = out.trim_matches('_').to_string();
402 let out = if out.is_empty() {
403 "tool".to_string()
404 } else {
405 out
406 };
407 if harn_lexer::KEYWORDS.contains(&out.as_str()) {
408 format!("tool_{out}")
409 } else {
410 out
411 }
412}