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}