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.get_project",
769            "mcp",
770            "mcp.github.get_project",
771            &[
772                "mcp.github_get_project",
773                "github_get_project",
774                "get_project",
775            ],
776        ),
777        make_binding(
778            "github.list_project_items",
779            "mcp",
780            "mcp.github.list_project_items",
781            &[
782                "mcp.github_list_project_items",
783                "github_list_project_items",
784                "list_project_items",
785            ],
786        ),
787        make_binding(
788            "github.update_project_item_field",
789            "mcp",
790            "mcp.github.update_project_item_field",
791            &[
792                "mcp.github_update_project_item_field",
793                "github_update_project_item_field",
794                "update_project_item_field",
795            ],
796        ),
797        make_binding(
798            "github.comment_on_issue",
799            "composio",
800            "mcp.composio.github_create_issue_comment",
801            &[
802                "mcp.composio.github.comment_on_issue",
803                "mcp.github.create_issue_comment",
804                "mcp.github_create_issue_comment",
805                "mcp.github.create_an_issue_comment",
806                "mcp.github_create_an_issue_comment",
807                "github_create_issue_comment",
808                "github_create_an_issue_comment",
809            ],
810        ),
811        make_binding(
812            "github.comment_on_issue",
813            "mcp",
814            "mcp.github.create_issue_comment",
815            &[
816                "mcp.github.add_issue_comment",
817                "mcp.github_add_issue_comment",
818                "add_issue_comment",
819                "mcp.github_create_issue_comment",
820                "mcp.github.create_an_issue_comment",
821                "mcp.github_create_an_issue_comment",
822                "github_create_issue_comment",
823                "github_create_an_issue_comment",
824            ],
825        ),
826        make_binding(
827            "github.comment_on_pull_request",
828            "composio",
829            "mcp.composio.github_create_pull_request_review_comment",
830            &["mcp.composio.github.comment_on_pull_request"],
831        ),
832        make_binding(
833            "github.comment_on_pull_request",
834            "mcp",
835            "mcp.github.comment_on_pull_request",
836            &[
837                "mcp.github_create_pull_request_review_comment",
838                "mcp.github.comment_pull_request",
839                "github_comment_on_pull_request",
840            ],
841        ),
842        make_binding(
843            "github.merge_pull_request",
844            "composio",
845            "mcp.composio.github_merge_pull_request",
846            &["mcp.composio.github.merge_pull_request"],
847        ),
848        make_binding(
849            "github.merge_pull_request",
850            "mcp",
851            "mcp.github.merge_pull_request",
852            &[
853                "mcp.github_merge_pull_request",
854                "github_merge_pull_request",
855                "merge_pull_request",
856            ],
857        ),
858        make_binding(
859            "github.list_repositories",
860            "composio",
861            "mcp.composio.github_list_repositories",
862            &["mcp.composio.github.list_repositories"],
863        ),
864        make_binding(
865            "slack.post_message",
866            "composio",
867            "mcp.composio.slack_post_message",
868            &["mcp.composio.slack.post_message"],
869        ),
870        make_binding(
871            "slack.post_message",
872            "arcade",
873            "mcp.arcade.slack_post_message",
874            &["mcp.arcade.slack.post_message"],
875        ),
876        make_binding(
877            "slack.reply_in_thread",
878            "composio",
879            "mcp.composio.slack_reply_to_thread",
880            &[
881                "mcp.composio.slack_reply_in_thread",
882                "mcp.composio.slack.reply_in_thread",
883            ],
884        ),
885        make_binding(
886            "slack.update_message",
887            "composio",
888            "mcp.composio.slack_update_message",
889            &["mcp.composio.slack.update_message"],
890        ),
891        make_binding(
892            "slack.list_channels",
893            "composio",
894            "mcp.composio.slack_list_channels",
895            &["mcp.composio.slack.list_channels"],
896        ),
897        make_binding(
898            "slack.get_channel_history",
899            "composio",
900            "mcp.composio.slack_get_channel_history",
901            &["mcp.composio.slack.get_channel_history"],
902        ),
903    ]
904}
905
906fn make_binding(
907    capability_id: &str,
908    provider: &str,
909    tool_name: &str,
910    aliases: &[&str],
911) -> CapabilityBinding {
912    let binding_key = builtin_binding_key(capability_id, provider, tool_name);
913    CapabilityBinding {
914        capability_id: capability_id.to_string(),
915        provider: provider.to_string(),
916        tool_name: tool_name.to_string(),
917        tool_name_aliases: aliases.iter().map(|row| row.to_string()).collect(),
918        request_transform: None,
919        response_transform: None,
920        metadata: json!({
921            "spine": true,
922            "spine_version": BUILTIN_CAPABILITY_BINDINGS_VERSION,
923            "binding_key": binding_key,
924        }),
925    }
926}
927
928fn canonical_tool_name(name: &str) -> String {
929    let mut out = String::new();
930    let mut last_was_sep = false;
931    for ch in name.chars().flat_map(|c| c.to_lowercase()) {
932        if ch.is_ascii_alphanumeric() {
933            out.push(ch);
934            last_was_sep = false;
935        } else if !last_was_sep {
936            out.push('_');
937            last_was_sep = true;
938        }
939    }
940    out.trim_matches('_').to_string()
941}
942
943pub fn canonicalize_tool_name(name: &str) -> String {
944    canonical_tool_name(name)
945}
946
947fn binding_matches_available(
948    binding: &CapabilityBinding,
949    provider: &str,
950    available_set: &HashSet<(String, String)>,
951) -> bool {
952    let mut names = Vec::with_capacity(1 + binding.tool_name_aliases.len());
953    names.push(binding.tool_name.as_str());
954    for alias in &binding.tool_name_aliases {
955        names.push(alias.as_str());
956    }
957    let expected = names
958        .into_iter()
959        .map(canonical_tool_name)
960        .collect::<Vec<_>>();
961    available_set
962        .iter()
963        .any(|(available_provider, available_tool)| {
964            if available_provider != provider {
965                return false;
966            }
967            expected.iter().any(|candidate| {
968                available_tool == candidate || available_tool.ends_with(&format!("_{candidate}"))
969            })
970        })
971}
972
973#[cfg(test)]
974mod tests {
975    use super::*;
976
977    #[tokio::test]
978    async fn resolve_prefers_composio_over_arcade_by_default() {
979        let root =
980            std::env::temp_dir().join(format!("tandem-cap-resolver-{}", uuid::Uuid::new_v4()));
981        let resolver = CapabilityResolver::new(root.clone());
982        let result = resolver
983            .resolve(
984                CapabilityResolveInput {
985                    workflow_id: Some("wf-1".to_string()),
986                    required_capabilities: vec!["github.create_pull_request".to_string()],
987                    optional_capabilities: vec![],
988                    provider_preference: vec![],
989                    available_tools: vec![
990                        CapabilityToolAvailability {
991                            provider: "arcade".to_string(),
992                            tool_name: "mcp.arcade.github_create_pull_request".to_string(),
993                            schema: Value::Null,
994                        },
995                        CapabilityToolAvailability {
996                            provider: "composio".to_string(),
997                            tool_name: "mcp.composio.github_create_pull_request".to_string(),
998                            schema: Value::Null,
999                        },
1000                    ],
1001                },
1002                Vec::new(),
1003            )
1004            .await
1005            .expect("resolve");
1006        assert_eq!(result.missing_required, Vec::<String>::new());
1007        assert_eq!(result.resolved.len(), 1);
1008        assert_eq!(result.resolved[0].provider, "composio");
1009        let _ = std::fs::remove_dir_all(root);
1010    }
1011
1012    #[tokio::test]
1013    async fn resolve_returns_missing_capability_when_unavailable() {
1014        let root =
1015            std::env::temp_dir().join(format!("tandem-cap-resolver-{}", uuid::Uuid::new_v4()));
1016        let resolver = CapabilityResolver::new(root.clone());
1017        let result = resolver
1018            .resolve(
1019                CapabilityResolveInput {
1020                    workflow_id: Some("wf-2".to_string()),
1021                    required_capabilities: vec!["github.create_pull_request".to_string()],
1022                    optional_capabilities: vec![],
1023                    provider_preference: vec!["arcade".to_string()],
1024                    available_tools: vec![],
1025                },
1026                Vec::new(),
1027            )
1028            .await
1029            .expect("resolve");
1030        assert_eq!(
1031            result.missing_required,
1032            vec!["github.create_pull_request".to_string()]
1033        );
1034        let _ = std::fs::remove_dir_all(root);
1035    }
1036
1037    #[tokio::test]
1038    async fn resolve_matches_alias_with_name_normalization() {
1039        let root =
1040            std::env::temp_dir().join(format!("tandem-cap-resolver-{}", uuid::Uuid::new_v4()));
1041        let resolver = CapabilityResolver::new(root.clone());
1042        let result = resolver
1043            .resolve(
1044                CapabilityResolveInput {
1045                    workflow_id: Some("wf-3".to_string()),
1046                    required_capabilities: vec!["slack.reply_in_thread".to_string()],
1047                    optional_capabilities: vec![],
1048                    provider_preference: vec![],
1049                    available_tools: vec![CapabilityToolAvailability {
1050                        provider: "composio".to_string(),
1051                        tool_name: "mcp.composio.slack.reply.in.thread".to_string(),
1052                        schema: Value::Null,
1053                    }],
1054                },
1055                Vec::new(),
1056            )
1057            .await
1058            .expect("resolve");
1059        assert_eq!(result.missing_required, Vec::<String>::new());
1060        assert_eq!(result.resolved.len(), 1);
1061        assert_eq!(result.resolved[0].capability_id, "slack.reply_in_thread");
1062        let _ = std::fs::remove_dir_all(root);
1063    }
1064
1065    #[tokio::test]
1066    async fn resolve_honors_explicit_provider_preference() {
1067        let root =
1068            std::env::temp_dir().join(format!("tandem-cap-resolver-{}", uuid::Uuid::new_v4()));
1069        let resolver = CapabilityResolver::new(root.clone());
1070        let result = resolver
1071            .resolve(
1072                CapabilityResolveInput {
1073                    workflow_id: Some("wf-4".to_string()),
1074                    required_capabilities: vec!["github.create_pull_request".to_string()],
1075                    optional_capabilities: vec![],
1076                    provider_preference: vec!["arcade".to_string(), "composio".to_string()],
1077                    available_tools: vec![
1078                        CapabilityToolAvailability {
1079                            provider: "composio".to_string(),
1080                            tool_name: "mcp.composio.github_create_pull_request".to_string(),
1081                            schema: Value::Null,
1082                        },
1083                        CapabilityToolAvailability {
1084                            provider: "arcade".to_string(),
1085                            tool_name: "mcp.arcade.github_create_pull_request".to_string(),
1086                            schema: Value::Null,
1087                        },
1088                    ],
1089                },
1090                Vec::new(),
1091            )
1092            .await
1093            .expect("resolve");
1094        assert_eq!(result.missing_required, Vec::<String>::new());
1095        assert_eq!(result.resolved.len(), 1);
1096        assert_eq!(result.resolved[0].provider, "arcade");
1097        let _ = std::fs::remove_dir_all(root);
1098    }
1099
1100    #[tokio::test]
1101    async fn resolve_matches_official_github_mcp_issue_tools() {
1102        let root =
1103            std::env::temp_dir().join(format!("tandem-cap-resolver-{}", uuid::Uuid::new_v4()));
1104        let resolver = CapabilityResolver::new(root.clone());
1105        let result = resolver
1106            .resolve(
1107                CapabilityResolveInput {
1108                    workflow_id: Some("wf-github-only".to_string()),
1109                    required_capabilities: vec![
1110                        "github.list_issues".to_string(),
1111                        "github.get_issue".to_string(),
1112                        "github.create_issue".to_string(),
1113                        "github.comment_on_issue".to_string(),
1114                    ],
1115                    optional_capabilities: vec![],
1116                    provider_preference: vec!["mcp".to_string()],
1117                    available_tools: vec![
1118                        CapabilityToolAvailability {
1119                            provider: "mcp".to_string(),
1120                            tool_name: "mcp.github_only.github_list_repository_issues".to_string(),
1121                            schema: Value::Null,
1122                        },
1123                        CapabilityToolAvailability {
1124                            provider: "mcp".to_string(),
1125                            tool_name: "mcp.github_only.github_create_an_issue".to_string(),
1126                            schema: Value::Null,
1127                        },
1128                        CapabilityToolAvailability {
1129                            provider: "mcp".to_string(),
1130                            tool_name: "mcp.github_only.github_create_an_issue_comment".to_string(),
1131                            schema: Value::Null,
1132                        },
1133                    ],
1134                },
1135                Vec::new(),
1136            )
1137            .await
1138            .expect("resolve");
1139        assert_eq!(result.missing_required, Vec::<String>::new());
1140        assert_eq!(result.resolved.len(), 4);
1141        let _ = std::fs::remove_dir_all(root);
1142    }
1143
1144    #[tokio::test]
1145    async fn resolve_matches_githubcopilot_issue_tools() {
1146        let root =
1147            std::env::temp_dir().join(format!("tandem-cap-resolver-{}", uuid::Uuid::new_v4()));
1148        let resolver = CapabilityResolver::new(root.clone());
1149        let result = resolver
1150            .resolve(
1151                CapabilityResolveInput {
1152                    workflow_id: Some("wf-githubcopilot".to_string()),
1153                    required_capabilities: vec![
1154                        "github.list_issues".to_string(),
1155                        "github.get_issue".to_string(),
1156                        "github.create_issue".to_string(),
1157                        "github.comment_on_issue".to_string(),
1158                    ],
1159                    optional_capabilities: vec![],
1160                    provider_preference: vec!["mcp".to_string()],
1161                    available_tools: vec![
1162                        CapabilityToolAvailability {
1163                            provider: "mcp".to_string(),
1164                            tool_name: "mcp.githubcopilot.list_issues".to_string(),
1165                            schema: Value::Null,
1166                        },
1167                        CapabilityToolAvailability {
1168                            provider: "mcp".to_string(),
1169                            tool_name: "mcp.githubcopilot.issue_read".to_string(),
1170                            schema: Value::Null,
1171                        },
1172                        CapabilityToolAvailability {
1173                            provider: "mcp".to_string(),
1174                            tool_name: "mcp.githubcopilot.issue_write".to_string(),
1175                            schema: Value::Null,
1176                        },
1177                        CapabilityToolAvailability {
1178                            provider: "mcp".to_string(),
1179                            tool_name: "mcp.githubcopilot.add_issue_comment".to_string(),
1180                            schema: Value::Null,
1181                        },
1182                    ],
1183                },
1184                Vec::new(),
1185            )
1186            .await
1187            .expect("resolve");
1188        assert_eq!(result.missing_required, Vec::<String>::new());
1189        assert_eq!(result.resolved.len(), 4);
1190        let _ = std::fs::remove_dir_all(root);
1191    }
1192
1193    #[tokio::test]
1194    async fn refresh_builtin_bindings_merges_new_spine_entries_into_existing_file() {
1195        let root =
1196            std::env::temp_dir().join(format!("tandem-cap-resolver-{}", uuid::Uuid::new_v4()));
1197        let resolver = CapabilityResolver::new(root.clone());
1198        let bindings_path = root.join("bindings").join("capability_bindings.json");
1199        std::fs::create_dir_all(bindings_path.parent().expect("bindings parent"))
1200            .expect("create bindings dir");
1201        let seeded = CapabilityBindingsFile {
1202            schema_version: "v1".to_string(),
1203            generated_at: None,
1204            builtin_version: Some("older-version".to_string()),
1205            last_merged_at_ms: Some(1),
1206            bindings: vec![CapabilityBinding {
1207                capability_id: "github.create_issue".to_string(),
1208                provider: "mcp".to_string(),
1209                tool_name: "mcp.github.create_issue".to_string(),
1210                tool_name_aliases: vec!["mcp.github_create_issue".to_string()],
1211                request_transform: None,
1212                response_transform: None,
1213                metadata: json!({
1214                    "spine": true,
1215                    "spine_version": "older-version",
1216                    "binding_key": builtin_binding_key(
1217                        "github.create_issue",
1218                        "mcp",
1219                        "mcp.github.create_issue"
1220                    ),
1221                }),
1222            }],
1223        };
1224        std::fs::write(
1225            &bindings_path,
1226            format!(
1227                "{}\n",
1228                serde_json::to_string_pretty(&seeded).expect("serialize seeded bindings")
1229            ),
1230        )
1231        .expect("write seeded bindings");
1232
1233        let merged_on_load = resolver.list_bindings().await.expect("list bindings");
1234        let summary = resolver.refresh_builtin_bindings().await.expect("refresh");
1235        let merged = resolver.list_bindings().await.expect("list bindings");
1236        assert_eq!(
1237            merged.builtin_version.as_deref(),
1238            Some(BUILTIN_CAPABILITY_BINDINGS_VERSION)
1239        );
1240        assert_eq!(
1241            merged_on_load.builtin_version.as_deref(),
1242            Some(BUILTIN_CAPABILITY_BINDINGS_VERSION)
1243        );
1244        assert!(
1245            summary.added_count + summary.updated_count + summary.unchanged_count > 0,
1246            "expected refresh summary to describe builtin bindings"
1247        );
1248        assert!(merged.bindings.iter().any(|row| {
1249            row.capability_id == "github.get_issue"
1250                && row.provider == "mcp"
1251                && row
1252                    .tool_name_aliases
1253                    .iter()
1254                    .any(|alias| alias == "issue_read")
1255        }));
1256        let _ = std::fs::remove_dir_all(root);
1257    }
1258}