1use crate::error::DispatchError;
18use crate::targets::{
19 CapabilityEntry, CapabilityOutcome, CapabilityProfile, Criticality, ProvidedBy, TargetId,
20};
21use serde::Deserialize;
22use std::borrow::Cow;
23use std::collections::HashMap;
24
25pub const ENGINE: &str = "claude-code";
27
28#[derive(Debug, Clone, Deserialize)]
30pub struct ProfileEntrySpec {
31 pub outcome: CapabilityOutcome,
32 #[serde(default)]
33 pub via: Option<String>,
34 pub provided_by: ProvidedBy,
35 pub criticality: Criticality,
36 #[serde(default)]
37 pub note: Option<String>,
38}
39
40impl From<ProfileEntrySpec> for CapabilityEntry {
41 fn from(spec: ProfileEntrySpec) -> Self {
42 CapabilityEntry {
43 outcome: spec.outcome,
44 via: spec.via.map(Cow::Owned),
45 provided_by: spec.provided_by,
46 criticality: spec.criticality,
47 note: spec.note.map(Cow::Owned),
48 }
49 }
50}
51
52#[derive(Debug, Clone, Deserialize)]
59pub struct ToolBindingSpec {
60 #[serde(default)]
61 pub name: Option<String>,
62 #[serde(default)]
63 pub names: Vec<String>,
64 pub source: String,
65}
66
67impl ToolBindingSpec {
68 fn all_names(&self) -> Vec<String> {
70 self.name
71 .iter()
72 .cloned()
73 .chain(self.names.iter().cloned())
74 .collect()
75 }
76}
77
78#[derive(Debug, Clone)]
81pub struct ToolBinding {
82 pub names: Vec<String>,
83 pub source: String,
84}
85
86pub type ToolMap = HashMap<String, ToolBinding>;
87
88#[derive(Debug, Clone, Deserialize)]
91pub struct ProviderFragment {
92 pub fragment_version: String,
93 pub provider: String,
94 pub engine: String,
95 #[serde(default)]
98 pub mode: Option<String>,
99 #[serde(default)]
100 pub capabilities: HashMap<String, ProfileEntrySpec>,
101 #[serde(default)]
102 pub tools: HashMap<String, ToolBindingSpec>,
103}
104
105#[derive(Debug, Clone, Deserialize)]
107#[serde(untagged)]
108enum ProviderFile {
109 List { providers: Vec<ProviderFragment> },
110 Single(ProviderFragment),
111}
112
113pub fn parse_provider_fragments(raw: &str) -> Result<Vec<ProviderFragment>, DispatchError> {
115 let file: ProviderFile = serde_yaml::from_str(raw)
116 .map_err(|e| DispatchError(format!("failed to parse provider fragment: {e}")))?;
117 Ok(match file {
118 ProviderFile::Single(fragment) => vec![fragment],
119 ProviderFile::List { providers } => providers,
120 })
121}
122
123pub struct ComposedTarget {
126 pub profile: CapabilityProfile,
127 pub tool_map: ToolMap,
128}
129
130fn validate_source_grammar(
132 provider_id: &str,
133 capability: &str,
134 source: &str,
135) -> Result<(), DispatchError> {
136 if let Some(rest) = source.strip_prefix("mcp:") {
137 let mut parts = rest.splitn(2, '/');
138 let server = parts.next().unwrap_or("");
139 let tool = parts.next();
140 if server.is_empty() || tool.is_none_or(str::is_empty) {
141 return Err(DispatchError(format!(
142 "provider '{provider_id}': capability '{capability}' has a malformed tool source \
143 '{source}' — expected 'mcp:<server>/<tool>'"
144 )));
145 }
146 } else if source.is_empty() || source.contains(':') || source.contains('/') {
147 return Err(DispatchError(format!(
148 "provider '{provider_id}': capability '{capability}' has a malformed tool source \
149 '{source}' — a bare mechanism label must not contain ':' or '/'"
150 )));
151 }
152 Ok(())
153}
154
155fn validate_via_source_coherence(
158 provider_id: &str,
159 capability: &str,
160 via: Option<&str>,
161 source: &str,
162) -> Result<(), DispatchError> {
163 let Some(rest) = source.strip_prefix("mcp:") else {
164 return Ok(());
165 };
166 let Some(server) = rest.split('/').next().filter(|s| !s.is_empty()) else {
167 return Ok(());
168 };
169 let expected = format!("mcp:{server}");
170 if via != Some(expected.as_str()) {
171 return Err(DispatchError(format!(
172 "provider '{provider_id}': capability '{capability}' has tool source '{source}' \
173 (server '{server}') but via is {via:?} — via should be '{expected}' so the report and \
174 the tool grant agree on the backing server"
175 )));
176 }
177 Ok(())
178}
179
180fn validate_names_match_source(
184 provider_id: &str,
185 capability: &str,
186 names: &[String],
187 source: &str,
188) -> Result<(), DispatchError> {
189 if names.is_empty() {
190 return Err(DispatchError(format!(
191 "provider '{provider_id}': tool binding for '{capability}' grants no tool — set `name` \
192 or `names`"
193 )));
194 }
195 let Some(rest) = source.strip_prefix("mcp:") else {
196 return Ok(());
197 };
198 let server = rest.split('/').next().unwrap_or("");
199 let expected_prefix = format!("mcp__{server}__");
200 for name in names {
201 if !name.starts_with(&expected_prefix) {
202 return Err(DispatchError(format!(
203 "provider '{provider_id}': tool binding for '{capability}' grants '{name}', which \
204 cannot come from server '{server}' — on {ENGINE} an MCP tool is spelled \
205 '{expected_prefix}<tool>'"
206 )));
207 }
208 }
209 Ok(())
210}
211
212fn base_locks_capability(base: &CapabilityProfile, capability: &str) -> bool {
216 base.get(capability).is_some_and(|e| {
217 e.criticality == Criticality::SafetyCritical || e.outcome != CapabilityOutcome::Fail
218 })
219}
220
221pub fn compose_target(
231 base_profile: CapabilityProfile,
232 base_tool_map: ToolMap,
233 providers: &[ProviderFragment],
234 target_id: TargetId,
235) -> Result<ComposedTarget, DispatchError> {
236 let base_profile_snapshot = base_profile.clone();
237 let base_tool_keys: Vec<String> = base_tool_map.keys().cloned().collect();
238 let mut profile = base_profile;
239 let mut tool_map = base_tool_map;
240 let mut claimed: HashMap<String, usize> = HashMap::new();
241
242 for (idx, fragment) in providers.iter().enumerate() {
243 if fragment.engine != ENGINE {
244 return Err(DispatchError(format!(
245 "provider '{}': engine '{}' does not match target engine '{ENGINE}'",
246 fragment.provider, fragment.engine
247 )));
248 }
249 if let Some(mode) = fragment.mode.as_deref() {
250 if mode != target_id.mode() {
251 continue;
252 }
253 }
254
255 for (capability, spec) in &fragment.capabilities {
256 if base_locks_capability(&base_profile_snapshot, capability) {
257 return Err(DispatchError(format!(
258 "provider '{}': capability '{capability}' is already provided by the base \
259 target and cannot be redefined",
260 fragment.provider
261 )));
262 }
263 claim_capability(&mut claimed, providers, capability, idx, fragment)?;
264 profile.insert(capability.clone(), spec.clone().into());
265 }
266
267 for (capability, spec) in &fragment.tools {
268 let names = spec.all_names();
269 validate_source_grammar(&fragment.provider, capability, &spec.source)?;
270 validate_via_source_coherence(
271 &fragment.provider,
272 capability,
273 profile.get(capability).and_then(|e| e.via.as_deref()),
274 &spec.source,
275 )?;
276 validate_names_match_source(&fragment.provider, capability, &names, &spec.source)?;
277 if base_locks_capability(&base_profile_snapshot, capability)
278 || base_tool_keys.iter().any(|k| k == capability)
279 {
280 return Err(DispatchError(format!(
281 "provider '{}': tool binding for '{capability}' is already provided by the \
282 base target and cannot be redefined",
283 fragment.provider
284 )));
285 }
286 claim_capability(&mut claimed, providers, capability, idx, fragment)?;
287 if !profile.contains_key(capability) {
288 return Err(DispatchError(format!(
289 "provider '{}': tool binding for '{capability}' has no corresponding \
290 capability profile entry (from the base or this provider)",
291 fragment.provider
292 )));
293 }
294 tool_map.insert(
295 capability.clone(),
296 ToolBinding {
297 names,
298 source: spec.source.clone(),
299 },
300 );
301 }
302 }
303
304 Ok(ComposedTarget { profile, tool_map })
305}
306
307fn claim_capability(
308 claimed: &mut HashMap<String, usize>,
309 providers: &[ProviderFragment],
310 capability: &str,
311 idx: usize,
312 fragment: &ProviderFragment,
313) -> Result<(), DispatchError> {
314 match claimed.get(capability) {
315 Some(&owner) if owner != idx => Err(DispatchError(format!(
316 "capability '{capability}' is claimed by both provider '{}' and provider '{}' — at \
317 most one provider source per capability",
318 providers[owner].provider, fragment.provider
319 ))),
320 Some(_) => Ok(()),
321 None => {
322 claimed.insert(capability.to_string(), idx);
323 Ok(())
324 }
325 }
326}
327
328pub fn compose_for_conformance(
332 base_profile: CapabilityProfile,
333 providers: &[ProviderFragment],
334 target_id: TargetId,
335) -> Result<Vec<String>, DispatchError> {
336 let composed = compose_target(base_profile, ToolMap::new(), providers, target_id)?;
337 Ok(composed.profile.into_keys().collect())
338}