Skip to main content

warble_claude_code/
provider.rs

1//! Provider fragments — the dispatch-time supplement to this target's capability profile and tool
2//! grants.
3//!
4//! The base `claude-code` target (`targets.rs`) declares only **substrate** capabilities: LLM tiers,
5//! the render and structured-output contracts, the authz/approval/blast-radius gates, version
6//! control, context isolation. Those describe the runtime itself. Any **domain** capability — an
7//! external service the component talks to, and the tools that reach it — is supplied at dispatch by
8//! one or more provider fragments loaded through the CLI's repeatable `--provider` flag, so warble
9//! never hardcodes whose product realizes a capability.
10//!
11//! This mirrors `dispatcher/vercel/src/provider.rs`. The two are deliberately separate
12//! implementations of one written contract (`docs/spec/provider-fragment.md`), the same arrangement
13//! `binding-spec.md` describes for the tier→model binding: no shared codegen, a shared spec, and a
14//! shared conformance fixture (`dispatcher/conformance-fixtures/provider-composition.json`) that
15//! fails whichever side drifts.
16
17use 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
25/// The engine a fragment must declare to apply here.
26pub const ENGINE: &str = "claude-code";
27
28/// One capability's profile-entry contribution from a fragment.
29#[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/// One capability's tool binding.
53///
54/// `name`/`names` is the tool **as this engine spells it** — for Claude Code, the allowlist entry,
55/// e.g. `mcp__remote_agent__ask`. A capability may need more than one callable (asking a service and
56/// answering the question it asks back are one ability, two tools), so the list form exists; the
57/// single-`name` form is the same thing with one entry and is what a vercel-shaped fragment uses.
58#[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    /// Every callable this binding grants, single and list forms normalized together.
69    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/// A resolved tool binding: the callables to grant, plus where they are realized (provenance for
79/// the report — the grant itself is just the names).
80#[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/// A single provider fragment: a partial target definition. Field names are snake_case to match the
89/// fragment YAML verbatim.
90#[derive(Debug, Clone, Deserialize)]
91pub struct ProviderFragment {
92    pub fragment_version: String,
93    pub provider: String,
94    pub engine: String,
95    /// Restrict to one mode (`"headless"` | `"interactive"`); omitted ⇒ both. A fragment whose mode
96    /// does not match is skipped rather than rejected, so one file can carry both halves.
97    #[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/// A fragment file: either one fragment, or `{ providers: [...] }`.
106#[derive(Debug, Clone, Deserialize)]
107#[serde(untagged)]
108enum ProviderFile {
109    List { providers: Vec<ProviderFragment> },
110    Single(ProviderFragment),
111}
112
113/// Parse one fragment file's contents. Pure — file I/O stays in the CLI.
114pub 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
123/// A target's profile and tool map after composition, carried together so callers never juggle two
124/// separately-composed halves.
125pub struct ComposedTarget {
126    pub profile: CapabilityProfile,
127    pub tool_map: ToolMap,
128}
129
130/// `source` grammar: `mcp:<server>/<tool>`, or a bare mechanism label carrying neither `:` nor `/`.
131fn 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
155/// When a source is `mcp:<S>/<tool>`, the capability's `via` must be `mcp:<S>` — otherwise the
156/// resolution report and the tool grant disagree about which server is behind the capability.
157fn 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
180/// A grant must name a tool the runtime can actually be told to allow. Claude Code spells an MCP
181/// tool `mcp__<server>__<tool>`, and a fragment that says otherwise produces an allowlist entry that
182/// silently matches nothing — a tool the agent is told it has and then cannot call.
183fn 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
212/// Whether the base owns `capability` and a provider may therefore not redefine it: safety-critical
213/// unconditionally, or any outcome other than `fail`. A base entry that is merely a non-safety
214/// `fail` is the "raise a base fail into support" case a provider is allowed to take.
215fn 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
221/// Compose the base profile and tool map with a flattened fragment set.
222///
223/// 1. A provider may only add a capability the base lacks, or raise a base `fail` into support.
224/// 2. It may never weaken a safety-critical base capability nor redefine one the base provides.
225/// 3. At most one fragment may claim a capability, across both maps. Ownership is the fragment's
226///    *index*, never its self-declared `provider` string — that string comes from the file, so two
227///    fragments sharing one would otherwise not collide and the later would silently win.
228/// 4. A tool binding needs its capability's profile entry to exist (from the base or the same
229///    fragment) by the time it is processed.
230pub 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
328/// Compose against an empty tool map and report only the capability keys — the shape the shared
329/// conformance fixture asserts. Exposed for that suite rather than for the emit path, which needs
330/// the whole composed target and reaches `compose_target` directly.
331pub 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}