Skip to main content

tandem_server/
capability_resolver.rs

1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::path::PathBuf;
3use std::sync::Arc;
4use std::time::{SystemTime, UNIX_EPOCH};
5
6use anyhow::anyhow;
7use serde::{Deserialize, Serialize};
8use serde_json::{json, Value};
9use tokio::sync::Mutex;
10
11pub const BUILTIN_CAPABILITY_BINDINGS_VERSION: &str = "2026-03-07-github-mcp-v1";
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
14pub struct CapabilityBinding {
15    pub capability_id: String,
16    pub provider: String,
17    pub tool_name: String,
18    #[serde(default)]
19    pub tool_name_aliases: Vec<String>,
20    #[serde(default)]
21    pub request_transform: Option<Value>,
22    #[serde(default)]
23    pub response_transform: Option<Value>,
24    #[serde(default)]
25    pub metadata: Value,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct CapabilityBindingsFile {
30    pub schema_version: String,
31    #[serde(default)]
32    pub generated_at: Option<String>,
33    #[serde(default)]
34    pub builtin_version: Option<String>,
35    #[serde(default)]
36    pub last_merged_at_ms: Option<u64>,
37    #[serde(default)]
38    pub bindings: Vec<CapabilityBinding>,
39}
40
41impl Default for CapabilityBindingsFile {
42    fn default() -> Self {
43        Self {
44            schema_version: "v1".to_string(),
45            generated_at: None,
46            builtin_version: Some(BUILTIN_CAPABILITY_BINDINGS_VERSION.to_string()),
47            last_merged_at_ms: Some(now_ms()),
48            bindings: default_spine_bindings(),
49        }
50    }
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, Default)]
54pub struct CapabilityBindingsRefreshResult {
55    #[serde(default)]
56    pub added_count: usize,
57    #[serde(default)]
58    pub updated_count: usize,
59    #[serde(default)]
60    pub unchanged_count: usize,
61    pub builtin_version: String,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub last_merged_at_ms: Option<u64>,
64}
65
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct CapabilityToolAvailability {
68    pub provider: String,
69    pub tool_name: String,
70    #[serde(default)]
71    pub schema: Value,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct CapabilityResolveInput {
76    #[serde(default)]
77    pub workflow_id: Option<String>,
78    #[serde(default)]
79    pub required_capabilities: Vec<String>,
80    #[serde(default)]
81    pub optional_capabilities: Vec<String>,
82    #[serde(default)]
83    pub provider_preference: Vec<String>,
84    #[serde(default)]
85    pub available_tools: Vec<CapabilityToolAvailability>,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct CapabilityReadinessInput {
90    #[serde(default)]
91    pub workflow_id: Option<String>,
92    #[serde(default)]
93    pub required_capabilities: Vec<String>,
94    #[serde(default)]
95    pub optional_capabilities: Vec<String>,
96    #[serde(default)]
97    pub provider_preference: Vec<String>,
98    #[serde(default)]
99    pub available_tools: Vec<CapabilityToolAvailability>,
100    #[serde(default)]
101    pub allow_unbound: bool,
102}
103
104#[derive(Debug, Clone, Serialize, Deserialize)]
105pub struct CapabilityResolution {
106    pub capability_id: String,
107    pub provider: String,
108    pub tool_name: String,
109    pub binding_index: usize,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct CapabilityResolveOutput {
114    #[serde(default)]
115    pub resolved: Vec<CapabilityResolution>,
116    #[serde(default)]
117    pub missing_required: Vec<String>,
118    #[serde(default)]
119    pub missing_optional: Vec<String>,
120    #[serde(default)]
121    pub considered_bindings: usize,
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct CapabilityBlockingIssue {
126    pub code: String,
127    pub message: String,
128    #[serde(default)]
129    pub capability_ids: Vec<String>,
130    #[serde(default)]
131    pub providers: Vec<String>,
132    #[serde(default)]
133    pub tools: Vec<String>,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct CapabilityReadinessOutput {
138    pub workflow_id: String,
139    pub runnable: bool,
140    #[serde(default)]
141    pub resolved: Vec<CapabilityResolution>,
142    #[serde(default)]
143    pub missing_required_capabilities: Vec<String>,
144    #[serde(default)]
145    pub unbound_capabilities: Vec<String>,
146    #[serde(default)]
147    pub missing_optional_capabilities: Vec<String>,
148    #[serde(default)]
149    pub missing_servers: Vec<String>,
150    #[serde(default)]
151    pub disconnected_servers: Vec<String>,
152    #[serde(default)]
153    pub auth_pending_tools: Vec<String>,
154    #[serde(default)]
155    pub missing_secret_refs: Vec<String>,
156    pub considered_bindings: usize,
157    #[serde(default)]
158    pub recommendations: Vec<String>,
159    #[serde(default)]
160    pub blocking_issues: Vec<CapabilityBlockingIssue>,
161}
162
163#[derive(Clone)]
164pub struct CapabilityResolver {
165    bindings_path: PathBuf,
166    lock: Arc<Mutex<()>>,
167}
168
169impl CapabilityResolver {
170    pub fn new(root: PathBuf) -> Self {
171        Self {
172            bindings_path: root.join("bindings").join("capability_bindings.json"),
173            lock: Arc::new(Mutex::new(())),
174        }
175    }
176
177    pub async fn list_bindings(&self) -> anyhow::Result<CapabilityBindingsFile> {
178        self.read_bindings().await
179    }
180
181    pub async fn set_bindings(&self, file: CapabilityBindingsFile) -> anyhow::Result<()> {
182        let _guard = self.lock.lock().await;
183        self.write_bindings_locked(file).await?;
184        Ok(())
185    }
186
187    pub async fn refresh_builtin_bindings(
188        &self,
189    ) -> anyhow::Result<CapabilityBindingsRefreshResult> {
190        let _guard = self.lock.lock().await;
191        let existing = self.read_bindings_locked().await?;
192        let (merged, summary, changed) = merge_builtin_bindings(existing);
193        if changed {
194            self.write_bindings_locked(merged.clone()).await?;
195        }
196        Ok(summary)
197    }
198
199    pub async fn reset_to_builtin_bindings(
200        &self,
201    ) -> anyhow::Result<CapabilityBindingsRefreshResult> {
202        let _guard = self.lock.lock().await;
203        let file = CapabilityBindingsFile::default();
204        let summary = CapabilityBindingsRefreshResult {
205            added_count: file.bindings.len(),
206            updated_count: 0,
207            unchanged_count: 0,
208            builtin_version: file
209                .builtin_version
210                .clone()
211                .unwrap_or_else(|| BUILTIN_CAPABILITY_BINDINGS_VERSION.to_string()),
212            last_merged_at_ms: file.last_merged_at_ms,
213        };
214        self.write_bindings_locked(file).await?;
215        Ok(summary)
216    }
217
218    async fn write_bindings_locked(&self, mut file: CapabilityBindingsFile) -> anyhow::Result<()> {
219        validate_bindings(&file)?;
220        if file.builtin_version.is_none() {
221            file.builtin_version = Some(BUILTIN_CAPABILITY_BINDINGS_VERSION.to_string());
222        }
223        if file.last_merged_at_ms.is_none() {
224            file.last_merged_at_ms = Some(now_ms());
225        }
226        if let Some(parent) = self.bindings_path.parent() {
227            tokio::fs::create_dir_all(parent).await?;
228        }
229        let payload = serde_json::to_string_pretty(&file)?;
230        tokio::fs::write(&self.bindings_path, format!("{}\n", payload)).await?;
231        Ok(())
232    }
233
234    pub async fn resolve(
235        &self,
236        input: CapabilityResolveInput,
237        discovered_tools: Vec<CapabilityToolAvailability>,
238    ) -> anyhow::Result<CapabilityResolveOutput> {
239        let bindings = self.read_bindings().await?;
240        validate_bindings(&bindings)?;
241        let preference = if input.provider_preference.is_empty() {
242            vec![
243                "composio".to_string(),
244                "arcade".to_string(),
245                "mcp".to_string(),
246                "custom".to_string(),
247            ]
248        } else {
249            input.provider_preference.clone()
250        };
251        let pref_rank = preference
252            .iter()
253            .enumerate()
254            .map(|(i, provider)| (provider.to_ascii_lowercase(), i))
255            .collect::<HashMap<_, _>>();
256        let available = if input.available_tools.is_empty() {
257            discovered_tools
258        } else {
259            input.available_tools.clone()
260        };
261        let available_set = available
262            .iter()
263            .map(|row| {
264                (
265                    row.provider.to_ascii_lowercase(),
266                    canonical_tool_name(&row.tool_name),
267                )
268            })
269            .collect::<HashSet<_>>();
270
271        let mut all_capabilities = input.required_capabilities.clone();
272        for cap in &input.optional_capabilities {
273            if !all_capabilities.contains(cap) {
274                all_capabilities.push(cap.clone());
275            }
276        }
277
278        let mut resolved = Vec::new();
279        let mut missing_required = Vec::new();
280        let mut missing_optional = Vec::new();
281
282        let by_capability = group_bindings(&bindings.bindings);
283        for capability_id in all_capabilities {
284            let Some(candidates) = by_capability.get(&capability_id) else {
285                if input.required_capabilities.contains(&capability_id) {
286                    missing_required.push(capability_id);
287                } else {
288                    missing_optional.push(capability_id);
289                }
290                continue;
291            };
292            let mut chosen: Option<(usize, &CapabilityBinding)> = None;
293            for (idx, candidate) in candidates {
294                let provider = candidate.provider.to_ascii_lowercase();
295                if !binding_matches_available(candidate, &provider, &available_set) {
296                    continue;
297                }
298                if let Some((chosen_idx, chosen_binding)) = chosen {
299                    let chosen_rank = pref_rank
300                        .get(&chosen_binding.provider.to_ascii_lowercase())
301                        .copied()
302                        .unwrap_or(usize::MAX);
303                    let this_rank = pref_rank.get(&provider).copied().unwrap_or(usize::MAX);
304                    if this_rank < chosen_rank || (this_rank == chosen_rank && *idx < chosen_idx) {
305                        chosen = Some((*idx, candidate));
306                    }
307                } else {
308                    chosen = Some((*idx, candidate));
309                }
310            }
311            if let Some((binding_index, binding)) = chosen {
312                resolved.push(CapabilityResolution {
313                    capability_id: capability_id.clone(),
314                    provider: binding.provider.clone(),
315                    tool_name: binding.tool_name.clone(),
316                    binding_index,
317                });
318            } else if input.required_capabilities.contains(&capability_id) {
319                missing_required.push(capability_id);
320            } else {
321                missing_optional.push(capability_id);
322            }
323        }
324
325        resolved.sort_by(|a, b| a.capability_id.cmp(&b.capability_id));
326        missing_required.sort();
327        missing_optional.sort();
328        Ok(CapabilityResolveOutput {
329            resolved,
330            missing_required,
331            missing_optional,
332            considered_bindings: bindings.bindings.len(),
333        })
334    }
335
336    pub async fn discover_from_runtime(
337        &self,
338        mcp_tools: Vec<tandem_runtime::McpRemoteTool>,
339        local_tools: Vec<tandem_types::ToolSchema>,
340    ) -> Vec<CapabilityToolAvailability> {
341        let mut out = Vec::new();
342        for tool in mcp_tools {
343            out.push(CapabilityToolAvailability {
344                provider: provider_from_tool_name(&tool.namespaced_name),
345                tool_name: tool.namespaced_name,
346                schema: tool.input_schema,
347            });
348        }
349        for tool in local_tools {
350            out.push(CapabilityToolAvailability {
351                provider: "custom".to_string(),
352                tool_name: tool.name,
353                schema: tool.input_schema,
354            });
355        }
356        out.sort_by(|a, b| {
357            a.provider
358                .cmp(&b.provider)
359                .then_with(|| a.tool_name.cmp(&b.tool_name))
360        });
361        out.dedup_by(|a, b| {
362            a.provider.eq_ignore_ascii_case(&b.provider)
363                && a.tool_name.eq_ignore_ascii_case(&b.tool_name)
364        });
365        out
366    }
367
368    pub fn missing_capability_error(
369        workflow_id: &str,
370        missing_capabilities: &[String],
371        available_capability_bindings: &HashMap<String, Vec<String>>,
372    ) -> Value {
373        let suggestions = missing_capabilities
374            .iter()
375            .map(|cap| {
376                let bindings = available_capability_bindings
377                    .get(cap)
378                    .cloned()
379                    .unwrap_or_default();
380                serde_json::json!({
381                    "capability_id": cap,
382                    "available_bindings": bindings,
383                })
384            })
385            .collect::<Vec<_>>();
386        serde_json::json!({
387            "code": "missing_capability",
388            "workflow_id": workflow_id,
389            "missing_capabilities": missing_capabilities,
390            "suggestions": suggestions,
391        })
392    }
393
394    async fn read_bindings(&self) -> anyhow::Result<CapabilityBindingsFile> {
395        let _guard = self.lock.lock().await;
396        self.read_bindings_locked().await
397    }
398
399    async fn read_bindings_locked(&self) -> anyhow::Result<CapabilityBindingsFile> {
400        if !self.bindings_path.exists() {
401            let default = CapabilityBindingsFile::default();
402            self.write_bindings_locked(default.clone()).await?;
403            return Ok(default);
404        }
405        let raw = tokio::fs::read_to_string(&self.bindings_path).await?;
406        let parsed = serde_json::from_str::<CapabilityBindingsFile>(&raw)?;
407        let (merged, _, changed) = merge_builtin_bindings(parsed);
408        if changed {
409            self.write_bindings_locked(merged.clone()).await?;
410        }
411        Ok(merged)
412    }
413}
414
415fn group_bindings(
416    bindings: &[CapabilityBinding],
417) -> BTreeMap<String, Vec<(usize, &CapabilityBinding)>> {
418    let mut map = BTreeMap::<String, Vec<(usize, &CapabilityBinding)>>::new();
419    for (idx, binding) in bindings.iter().enumerate() {
420        map.entry(binding.capability_id.clone())
421            .or_default()
422            .push((idx, binding));
423    }
424    map
425}
426
427pub fn classify_missing_required(
428    bindings: &CapabilityBindingsFile,
429    missing_required: &[String],
430) -> (Vec<String>, Vec<String>) {
431    let mut missing_capabilities = Vec::new();
432    let mut unbound_capabilities = Vec::new();
433    for capability_id in missing_required {
434        if bindings
435            .bindings
436            .iter()
437            .any(|binding| binding.capability_id == *capability_id)
438        {
439            unbound_capabilities.push(capability_id.clone());
440        } else {
441            missing_capabilities.push(capability_id.clone());
442        }
443    }
444    missing_capabilities.sort();
445    missing_capabilities.dedup();
446    unbound_capabilities.sort();
447    unbound_capabilities.dedup();
448    (missing_capabilities, unbound_capabilities)
449}
450
451pub fn providers_for_capability(
452    bindings: &CapabilityBindingsFile,
453    capability_id: &str,
454) -> Vec<String> {
455    let mut providers = bindings
456        .bindings
457        .iter()
458        .filter(|binding| binding.capability_id == capability_id)
459        .map(|binding| binding.provider.to_ascii_lowercase())
460        .collect::<Vec<_>>();
461    providers.sort();
462    providers.dedup();
463    providers
464}
465
466fn provider_from_tool_name(tool_name: &str) -> String {
467    let normalized = tool_name.to_ascii_lowercase();
468    if normalized.starts_with("mcp.composio.") {
469        return "composio".to_string();
470    }
471    if normalized.starts_with("mcp.arcade.") {
472        return "arcade".to_string();
473    }
474    if normalized.starts_with("mcp.") {
475        return "mcp".to_string();
476    }
477    "custom".to_string()
478}
479
480fn validate_bindings(file: &CapabilityBindingsFile) -> anyhow::Result<()> {
481    if file.schema_version.trim().is_empty() {
482        return Err(anyhow!("schema_version is required"));
483    }
484    for binding in &file.bindings {
485        if binding.capability_id.trim().is_empty() {
486            return Err(anyhow!("binding capability_id is required"));
487        }
488        if binding.provider.trim().is_empty() {
489            return Err(anyhow!("binding provider is required"));
490        }
491        if binding.tool_name.trim().is_empty() {
492            return Err(anyhow!("binding tool_name is required"));
493        }
494        for alias in &binding.tool_name_aliases {
495            if alias.trim().is_empty() {
496                return Err(anyhow!(
497                    "binding tool_name_aliases cannot contain empty values"
498                ));
499            }
500        }
501    }
502    Ok(())
503}
504
505fn now_ms() -> u64 {
506    SystemTime::now()
507        .duration_since(UNIX_EPOCH)
508        .map(|d| d.as_millis() as u64)
509        .unwrap_or(0)
510}
511
512fn builtin_binding_key(capability_id: &str, provider: &str, tool_name: &str) -> String {
513    format!(
514        "{}::{}::{}",
515        capability_id.trim().to_ascii_lowercase(),
516        provider.trim().to_ascii_lowercase(),
517        canonical_tool_name(tool_name)
518    )
519}
520
521fn binding_key(binding: &CapabilityBinding) -> String {
522    binding
523        .metadata
524        .get("binding_key")
525        .and_then(|v| v.as_str())
526        .map(str::trim)
527        .filter(|v| !v.is_empty())
528        .map(|v| v.to_string())
529        .unwrap_or_else(|| {
530            builtin_binding_key(
531                &binding.capability_id,
532                &binding.provider,
533                &binding.tool_name,
534            )
535        })
536}
537
538fn is_spine_binding(binding: &CapabilityBinding) -> bool {
539    binding
540        .metadata
541        .get("spine")
542        .and_then(|v| v.as_bool())
543        .unwrap_or(false)
544}
545
546fn merge_builtin_bindings(
547    existing: CapabilityBindingsFile,
548) -> (
549    CapabilityBindingsFile,
550    CapabilityBindingsRefreshResult,
551    bool,
552) {
553    let builtin = CapabilityBindingsFile::default();
554    let mut merged = existing.clone();
555    let mut added_count = 0usize;
556    let mut updated_count = 0usize;
557    let mut unchanged_count = 0usize;
558    let mut changed = false;
559
560    for builtin_binding in builtin.bindings {
561        let key = binding_key(&builtin_binding);
562        if let Some((idx, existing_binding)) = merged
563            .bindings
564            .iter()
565            .enumerate()
566            .find(|(_, row)| binding_key(row) == key)
567        {
568            if is_spine_binding(existing_binding) {
569                if existing_binding != &builtin_binding {
570                    merged.bindings[idx] = builtin_binding;
571                    updated_count += 1;
572                    changed = true;
573                } else {
574                    unchanged_count += 1;
575                }
576            } else {
577                unchanged_count += 1;
578            }
579        } else {
580            merged.bindings.push(builtin_binding);
581            added_count += 1;
582            changed = true;
583        }
584    }
585
586    let builtin_version = Some(BUILTIN_CAPABILITY_BINDINGS_VERSION.to_string());
587    if merged.builtin_version != builtin_version {
588        merged.builtin_version = builtin_version.clone();
589        changed = true;
590    }
591    if changed || merged.last_merged_at_ms.is_none() {
592        merged.last_merged_at_ms = Some(now_ms());
593        changed = true;
594    }
595    merged.schema_version = if merged.schema_version.trim().is_empty() {
596        "v1".to_string()
597    } else {
598        merged.schema_version
599    };
600
601    (
602        merged.clone(),
603        CapabilityBindingsRefreshResult {
604            added_count,
605            updated_count,
606            unchanged_count,
607            builtin_version: builtin_version
608                .unwrap_or_else(|| BUILTIN_CAPABILITY_BINDINGS_VERSION.to_string()),
609            last_merged_at_ms: merged.last_merged_at_ms,
610        },
611        changed,
612    )
613}
614
615fn default_spine_bindings() -> Vec<CapabilityBinding> {
616    vec![
617        make_binding(
618            "github.create_pull_request",
619            "composio",
620            "mcp.composio.github_create_pull_request",
621            &[
622                "mcp.composio.github.create_pull_request",
623                "mcp.composio.github_create_pr",
624            ],
625        ),
626        make_binding(
627            "github.create_pull_request",
628            "arcade",
629            "mcp.arcade.github_create_pull_request",
630            &["mcp.arcade.github.create_pull_request"],
631        ),
632        make_binding(
633            "github.create_pull_request",
634            "mcp",
635            "mcp.github.create_pull_request",
636            &["mcp.github_create_pull_request"],
637        ),
638        make_binding(
639            "github.create_issue",
640            "composio",
641            "mcp.composio.github_create_issue",
642            &["mcp.composio.github.create_issue"],
643        ),
644        make_binding(
645            "github.create_issue",
646            "arcade",
647            "mcp.arcade.github_create_issue",
648            &["mcp.arcade.github.create_issue"],
649        ),
650        make_binding(
651            "github.create_issue",
652            "mcp",
653            "mcp.github.create_issue",
654            &[
655                "mcp.github_create_issue",
656                "mcp.github.create_an_issue",
657                "mcp.github_create_an_issue",
658                "mcp.github.issue_write",
659                "mcp.github_issue_write",
660                "issue_write",
661                "github_create_issue",
662                "github_create_an_issue",
663            ],
664        ),
665        make_binding(
666            "github.list_issues",
667            "composio",
668            "mcp.composio.github_list_issues",
669            &[
670                "mcp.composio.github.list_issues",
671                "mcp.github.list_repository_issues",
672                "mcp.github_list_repository_issues",
673                "github_list_repository_issues",
674            ],
675        ),
676        make_binding(
677            "github.list_issues",
678            "mcp",
679            "mcp.github.list_repository_issues",
680            &[
681                "mcp.github.list_issues",
682                "mcp.github_list_issues",
683                "list_issues",
684                "mcp.github_list_repository_issues",
685                "github_list_repository_issues",
686            ],
687        ),
688        make_binding(
689            "github.get_issue",
690            "composio",
691            "mcp.composio.github_get_issue",
692            &[
693                "mcp.composio.github.get_issue",
694                "mcp.github.get_issue",
695                "mcp.github_get_issue",
696                "mcp.github.find_issues",
697                "mcp.github_find_issues",
698                "mcp.github.list_repository_issues",
699                "mcp.github_list_repository_issues",
700                "github_get_issue",
701                "github_find_issues",
702                "github_list_repository_issues",
703            ],
704        ),
705        make_binding(
706            "github.get_issue",
707            "mcp",
708            "mcp.github.get_issue",
709            &[
710                "mcp.github.issue_read",
711                "mcp.github_issue_read",
712                "issue_read",
713                "mcp.github_get_issue",
714                "mcp.github.find_issues",
715                "mcp.github_find_issues",
716                "mcp.github.list_repository_issues",
717                "mcp.github_list_repository_issues",
718                "github_get_issue",
719                "github_find_issues",
720                "github_list_repository_issues",
721            ],
722        ),
723        make_binding(
724            "github.close_issue",
725            "composio",
726            "mcp.composio.github_close_issue",
727            &["mcp.composio.github.close_issue"],
728        ),
729        make_binding(
730            "github.create_branch",
731            "composio",
732            "mcp.composio.github_create_branch",
733            &["mcp.composio.github.create_branch"],
734        ),
735        make_binding(
736            "github.list_pull_requests",
737            "composio",
738            "mcp.composio.github_list_pull_requests",
739            &["mcp.composio.github.list_pull_requests"],
740        ),
741        make_binding(
742            "github.list_pull_requests",
743            "mcp",
744            "mcp.github.list_pull_requests",
745            &[
746                "mcp.github_list_pull_requests",
747                "github_list_pull_requests",
748                "list_pull_requests",
749            ],
750        ),
751        make_binding(
752            "github.get_pull_request",
753            "composio",
754            "mcp.composio.github_get_pull_request",
755            &["mcp.composio.github.get_pull_request"],
756        ),
757        make_binding(
758            "github.get_pull_request",
759            "mcp",
760            "mcp.github.get_pull_request",
761            &[
762                "mcp.github_get_pull_request",
763                "github_get_pull_request",
764                "get_pull_request",
765            ],
766        ),
767        make_binding(
768            "github.comment_on_issue",
769            "composio",
770            "mcp.composio.github_create_issue_comment",
771            &[
772                "mcp.composio.github.comment_on_issue",
773                "mcp.github.create_issue_comment",
774                "mcp.github_create_issue_comment",
775                "mcp.github.create_an_issue_comment",
776                "mcp.github_create_an_issue_comment",
777                "github_create_issue_comment",
778                "github_create_an_issue_comment",
779            ],
780        ),
781        make_binding(
782            "github.comment_on_issue",
783            "mcp",
784            "mcp.github.create_issue_comment",
785            &[
786                "mcp.github.add_issue_comment",
787                "mcp.github_add_issue_comment",
788                "add_issue_comment",
789                "mcp.github_create_issue_comment",
790                "mcp.github.create_an_issue_comment",
791                "mcp.github_create_an_issue_comment",
792                "github_create_issue_comment",
793                "github_create_an_issue_comment",
794            ],
795        ),
796        make_binding(
797            "github.comment_on_pull_request",
798            "composio",
799            "mcp.composio.github_create_pull_request_review_comment",
800            &["mcp.composio.github.comment_on_pull_request"],
801        ),
802        make_binding(
803            "github.comment_on_pull_request",
804            "mcp",
805            "mcp.github.comment_on_pull_request",
806            &[
807                "mcp.github_create_pull_request_review_comment",
808                "mcp.github.comment_pull_request",
809                "github_comment_on_pull_request",
810            ],
811        ),
812        make_binding(
813            "github.merge_pull_request",
814            "composio",
815            "mcp.composio.github_merge_pull_request",
816            &["mcp.composio.github.merge_pull_request"],
817        ),
818        make_binding(
819            "github.merge_pull_request",
820            "mcp",
821            "mcp.github.merge_pull_request",
822            &[
823                "mcp.github_merge_pull_request",
824                "github_merge_pull_request",
825                "merge_pull_request",
826            ],
827        ),
828        make_binding(
829            "github.list_repositories",
830            "composio",
831            "mcp.composio.github_list_repositories",
832            &["mcp.composio.github.list_repositories"],
833        ),
834        make_binding(
835            "slack.post_message",
836            "composio",
837            "mcp.composio.slack_post_message",
838            &["mcp.composio.slack.post_message"],
839        ),
840        make_binding(
841            "slack.post_message",
842            "arcade",
843            "mcp.arcade.slack_post_message",
844            &["mcp.arcade.slack.post_message"],
845        ),
846        make_binding(
847            "slack.reply_in_thread",
848            "composio",
849            "mcp.composio.slack_reply_to_thread",
850            &[
851                "mcp.composio.slack_reply_in_thread",
852                "mcp.composio.slack.reply_in_thread",
853            ],
854        ),
855        make_binding(
856            "slack.update_message",
857            "composio",
858            "mcp.composio.slack_update_message",
859            &["mcp.composio.slack.update_message"],
860        ),
861        make_binding(
862            "slack.list_channels",
863            "composio",
864            "mcp.composio.slack_list_channels",
865            &["mcp.composio.slack.list_channels"],
866        ),
867        make_binding(
868            "slack.get_channel_history",
869            "composio",
870            "mcp.composio.slack_get_channel_history",
871            &["mcp.composio.slack.get_channel_history"],
872        ),
873    ]
874}
875
876fn make_binding(
877    capability_id: &str,
878    provider: &str,
879    tool_name: &str,
880    aliases: &[&str],
881) -> CapabilityBinding {
882    let binding_key = builtin_binding_key(capability_id, provider, tool_name);
883    CapabilityBinding {
884        capability_id: capability_id.to_string(),
885        provider: provider.to_string(),
886        tool_name: tool_name.to_string(),
887        tool_name_aliases: aliases.iter().map(|row| row.to_string()).collect(),
888        request_transform: None,
889        response_transform: None,
890        metadata: json!({
891            "spine": true,
892            "spine_version": BUILTIN_CAPABILITY_BINDINGS_VERSION,
893            "binding_key": binding_key,
894        }),
895    }
896}
897
898fn canonical_tool_name(name: &str) -> String {
899    let mut out = String::new();
900    let mut last_was_sep = false;
901    for ch in name.chars().flat_map(|c| c.to_lowercase()) {
902        if ch.is_ascii_alphanumeric() {
903            out.push(ch);
904            last_was_sep = false;
905        } else if !last_was_sep {
906            out.push('_');
907            last_was_sep = true;
908        }
909    }
910    out.trim_matches('_').to_string()
911}
912
913pub fn canonicalize_tool_name(name: &str) -> String {
914    canonical_tool_name(name)
915}
916
917fn binding_matches_available(
918    binding: &CapabilityBinding,
919    provider: &str,
920    available_set: &HashSet<(String, String)>,
921) -> bool {
922    let mut names = Vec::with_capacity(1 + binding.tool_name_aliases.len());
923    names.push(binding.tool_name.as_str());
924    for alias in &binding.tool_name_aliases {
925        names.push(alias.as_str());
926    }
927    let expected = names
928        .into_iter()
929        .map(canonical_tool_name)
930        .collect::<Vec<_>>();
931    available_set
932        .iter()
933        .any(|(available_provider, available_tool)| {
934            if available_provider != provider {
935                return false;
936            }
937            expected.iter().any(|candidate| {
938                available_tool == candidate || available_tool.ends_with(&format!("_{candidate}"))
939            })
940        })
941}
942
943#[cfg(test)]
944mod tests {
945    use super::*;
946
947    #[tokio::test]
948    async fn resolve_prefers_composio_over_arcade_by_default() {
949        let root =
950            std::env::temp_dir().join(format!("tandem-cap-resolver-{}", uuid::Uuid::new_v4()));
951        let resolver = CapabilityResolver::new(root.clone());
952        let result = resolver
953            .resolve(
954                CapabilityResolveInput {
955                    workflow_id: Some("wf-1".to_string()),
956                    required_capabilities: vec!["github.create_pull_request".to_string()],
957                    optional_capabilities: vec![],
958                    provider_preference: vec![],
959                    available_tools: vec![
960                        CapabilityToolAvailability {
961                            provider: "arcade".to_string(),
962                            tool_name: "mcp.arcade.github_create_pull_request".to_string(),
963                            schema: Value::Null,
964                        },
965                        CapabilityToolAvailability {
966                            provider: "composio".to_string(),
967                            tool_name: "mcp.composio.github_create_pull_request".to_string(),
968                            schema: Value::Null,
969                        },
970                    ],
971                },
972                Vec::new(),
973            )
974            .await
975            .expect("resolve");
976        assert_eq!(result.missing_required, Vec::<String>::new());
977        assert_eq!(result.resolved.len(), 1);
978        assert_eq!(result.resolved[0].provider, "composio");
979        let _ = std::fs::remove_dir_all(root);
980    }
981
982    #[tokio::test]
983    async fn resolve_returns_missing_capability_when_unavailable() {
984        let root =
985            std::env::temp_dir().join(format!("tandem-cap-resolver-{}", uuid::Uuid::new_v4()));
986        let resolver = CapabilityResolver::new(root.clone());
987        let result = resolver
988            .resolve(
989                CapabilityResolveInput {
990                    workflow_id: Some("wf-2".to_string()),
991                    required_capabilities: vec!["github.create_pull_request".to_string()],
992                    optional_capabilities: vec![],
993                    provider_preference: vec!["arcade".to_string()],
994                    available_tools: vec![],
995                },
996                Vec::new(),
997            )
998            .await
999            .expect("resolve");
1000        assert_eq!(
1001            result.missing_required,
1002            vec!["github.create_pull_request".to_string()]
1003        );
1004        let _ = std::fs::remove_dir_all(root);
1005    }
1006
1007    #[tokio::test]
1008    async fn resolve_matches_alias_with_name_normalization() {
1009        let root =
1010            std::env::temp_dir().join(format!("tandem-cap-resolver-{}", uuid::Uuid::new_v4()));
1011        let resolver = CapabilityResolver::new(root.clone());
1012        let result = resolver
1013            .resolve(
1014                CapabilityResolveInput {
1015                    workflow_id: Some("wf-3".to_string()),
1016                    required_capabilities: vec!["slack.reply_in_thread".to_string()],
1017                    optional_capabilities: vec![],
1018                    provider_preference: vec![],
1019                    available_tools: vec![CapabilityToolAvailability {
1020                        provider: "composio".to_string(),
1021                        tool_name: "mcp.composio.slack.reply.in.thread".to_string(),
1022                        schema: Value::Null,
1023                    }],
1024                },
1025                Vec::new(),
1026            )
1027            .await
1028            .expect("resolve");
1029        assert_eq!(result.missing_required, Vec::<String>::new());
1030        assert_eq!(result.resolved.len(), 1);
1031        assert_eq!(result.resolved[0].capability_id, "slack.reply_in_thread");
1032        let _ = std::fs::remove_dir_all(root);
1033    }
1034
1035    #[tokio::test]
1036    async fn resolve_honors_explicit_provider_preference() {
1037        let root =
1038            std::env::temp_dir().join(format!("tandem-cap-resolver-{}", uuid::Uuid::new_v4()));
1039        let resolver = CapabilityResolver::new(root.clone());
1040        let result = resolver
1041            .resolve(
1042                CapabilityResolveInput {
1043                    workflow_id: Some("wf-4".to_string()),
1044                    required_capabilities: vec!["github.create_pull_request".to_string()],
1045                    optional_capabilities: vec![],
1046                    provider_preference: vec!["arcade".to_string(), "composio".to_string()],
1047                    available_tools: vec![
1048                        CapabilityToolAvailability {
1049                            provider: "composio".to_string(),
1050                            tool_name: "mcp.composio.github_create_pull_request".to_string(),
1051                            schema: Value::Null,
1052                        },
1053                        CapabilityToolAvailability {
1054                            provider: "arcade".to_string(),
1055                            tool_name: "mcp.arcade.github_create_pull_request".to_string(),
1056                            schema: Value::Null,
1057                        },
1058                    ],
1059                },
1060                Vec::new(),
1061            )
1062            .await
1063            .expect("resolve");
1064        assert_eq!(result.missing_required, Vec::<String>::new());
1065        assert_eq!(result.resolved.len(), 1);
1066        assert_eq!(result.resolved[0].provider, "arcade");
1067        let _ = std::fs::remove_dir_all(root);
1068    }
1069
1070    #[tokio::test]
1071    async fn resolve_matches_official_github_mcp_issue_tools() {
1072        let root =
1073            std::env::temp_dir().join(format!("tandem-cap-resolver-{}", uuid::Uuid::new_v4()));
1074        let resolver = CapabilityResolver::new(root.clone());
1075        let result = resolver
1076            .resolve(
1077                CapabilityResolveInput {
1078                    workflow_id: Some("wf-github-only".to_string()),
1079                    required_capabilities: vec![
1080                        "github.list_issues".to_string(),
1081                        "github.get_issue".to_string(),
1082                        "github.create_issue".to_string(),
1083                        "github.comment_on_issue".to_string(),
1084                    ],
1085                    optional_capabilities: vec![],
1086                    provider_preference: vec!["mcp".to_string()],
1087                    available_tools: vec![
1088                        CapabilityToolAvailability {
1089                            provider: "mcp".to_string(),
1090                            tool_name: "mcp.github_only.github_list_repository_issues".to_string(),
1091                            schema: Value::Null,
1092                        },
1093                        CapabilityToolAvailability {
1094                            provider: "mcp".to_string(),
1095                            tool_name: "mcp.github_only.github_create_an_issue".to_string(),
1096                            schema: Value::Null,
1097                        },
1098                        CapabilityToolAvailability {
1099                            provider: "mcp".to_string(),
1100                            tool_name: "mcp.github_only.github_create_an_issue_comment".to_string(),
1101                            schema: Value::Null,
1102                        },
1103                    ],
1104                },
1105                Vec::new(),
1106            )
1107            .await
1108            .expect("resolve");
1109        assert_eq!(result.missing_required, Vec::<String>::new());
1110        assert_eq!(result.resolved.len(), 4);
1111        let _ = std::fs::remove_dir_all(root);
1112    }
1113
1114    #[tokio::test]
1115    async fn resolve_matches_githubcopilot_issue_tools() {
1116        let root =
1117            std::env::temp_dir().join(format!("tandem-cap-resolver-{}", uuid::Uuid::new_v4()));
1118        let resolver = CapabilityResolver::new(root.clone());
1119        let result = resolver
1120            .resolve(
1121                CapabilityResolveInput {
1122                    workflow_id: Some("wf-githubcopilot".to_string()),
1123                    required_capabilities: vec![
1124                        "github.list_issues".to_string(),
1125                        "github.get_issue".to_string(),
1126                        "github.create_issue".to_string(),
1127                        "github.comment_on_issue".to_string(),
1128                    ],
1129                    optional_capabilities: vec![],
1130                    provider_preference: vec!["mcp".to_string()],
1131                    available_tools: vec![
1132                        CapabilityToolAvailability {
1133                            provider: "mcp".to_string(),
1134                            tool_name: "mcp.githubcopilot.list_issues".to_string(),
1135                            schema: Value::Null,
1136                        },
1137                        CapabilityToolAvailability {
1138                            provider: "mcp".to_string(),
1139                            tool_name: "mcp.githubcopilot.issue_read".to_string(),
1140                            schema: Value::Null,
1141                        },
1142                        CapabilityToolAvailability {
1143                            provider: "mcp".to_string(),
1144                            tool_name: "mcp.githubcopilot.issue_write".to_string(),
1145                            schema: Value::Null,
1146                        },
1147                        CapabilityToolAvailability {
1148                            provider: "mcp".to_string(),
1149                            tool_name: "mcp.githubcopilot.add_issue_comment".to_string(),
1150                            schema: Value::Null,
1151                        },
1152                    ],
1153                },
1154                Vec::new(),
1155            )
1156            .await
1157            .expect("resolve");
1158        assert_eq!(result.missing_required, Vec::<String>::new());
1159        assert_eq!(result.resolved.len(), 4);
1160        let _ = std::fs::remove_dir_all(root);
1161    }
1162
1163    #[tokio::test]
1164    async fn refresh_builtin_bindings_merges_new_spine_entries_into_existing_file() {
1165        let root =
1166            std::env::temp_dir().join(format!("tandem-cap-resolver-{}", uuid::Uuid::new_v4()));
1167        let resolver = CapabilityResolver::new(root.clone());
1168        let bindings_path = root.join("bindings").join("capability_bindings.json");
1169        std::fs::create_dir_all(bindings_path.parent().expect("bindings parent"))
1170            .expect("create bindings dir");
1171        let seeded = CapabilityBindingsFile {
1172            schema_version: "v1".to_string(),
1173            generated_at: None,
1174            builtin_version: Some("older-version".to_string()),
1175            last_merged_at_ms: Some(1),
1176            bindings: vec![CapabilityBinding {
1177                capability_id: "github.create_issue".to_string(),
1178                provider: "mcp".to_string(),
1179                tool_name: "mcp.github.create_issue".to_string(),
1180                tool_name_aliases: vec!["mcp.github_create_issue".to_string()],
1181                request_transform: None,
1182                response_transform: None,
1183                metadata: json!({
1184                    "spine": true,
1185                    "spine_version": "older-version",
1186                    "binding_key": builtin_binding_key(
1187                        "github.create_issue",
1188                        "mcp",
1189                        "mcp.github.create_issue"
1190                    ),
1191                }),
1192            }],
1193        };
1194        std::fs::write(
1195            &bindings_path,
1196            format!(
1197                "{}\n",
1198                serde_json::to_string_pretty(&seeded).expect("serialize seeded bindings")
1199            ),
1200        )
1201        .expect("write seeded bindings");
1202
1203        let merged_on_load = resolver.list_bindings().await.expect("list bindings");
1204        let summary = resolver.refresh_builtin_bindings().await.expect("refresh");
1205        let merged = resolver.list_bindings().await.expect("list bindings");
1206        assert_eq!(
1207            merged.builtin_version.as_deref(),
1208            Some(BUILTIN_CAPABILITY_BINDINGS_VERSION)
1209        );
1210        assert_eq!(
1211            merged_on_load.builtin_version.as_deref(),
1212            Some(BUILTIN_CAPABILITY_BINDINGS_VERSION)
1213        );
1214        assert!(
1215            summary.added_count + summary.updated_count + summary.unchanged_count > 0,
1216            "expected refresh summary to describe builtin bindings"
1217        );
1218        assert!(merged.bindings.iter().any(|row| {
1219            row.capability_id == "github.get_issue"
1220                && row.provider == "mcp"
1221                && row
1222                    .tool_name_aliases
1223                    .iter()
1224                    .any(|alias| alias == "issue_read")
1225        }));
1226        let _ = std::fs::remove_dir_all(root);
1227    }
1228}