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}