Skip to main content

devboy_mcp/
signature_match.rs

1//! Tool signature matching — decides which upstream tools have local counterparts.
2//!
3//! The matcher runs at startup and on every upstream reconnect:
4//!
5//! 1. Collect the local tool catalogue (from `ToolHandler::available_tools()`).
6//! 2. Collect the raw (unprefixed) tool catalogue from every connected upstream proxy.
7//! 3. For every tool name present in both, check schema compatibility.
8//!
9//! # Cloud priority
10//!
11//! When a tool exists only upstream it stays remote — the local routing engine never
12//! synthesizes a local handler. When it exists only locally it behaves like a regular
13//! built-in tool (unchanged baseline behaviour). Only when both sides advertise the same
14//! name is the routing engine allowed to consider dispatching locally.
15//!
16//! # Graceful degradation
17//!
18//! If schemas disagree in a way the matcher cannot reconcile, the routing engine is
19//! expected to fall back to upstream for that tool (remote wins). The matcher flags the
20//! mismatch with a human-readable reason so callers can surface it in logs or status
21//! commands.
22
23use std::collections::HashMap;
24
25use serde_json::Value;
26
27use crate::protocol::ToolDefinition;
28
29/// Per-tool outcome of the matcher.
30#[derive(Debug, Clone)]
31pub struct ToolMatch {
32    /// The unprefixed tool name (`get_issues`, not `cloud__get_issues`).
33    pub tool_name: String,
34    /// True if the tool is implemented by the local `ToolHandler`.
35    pub local_present: bool,
36    /// True if at least one upstream proxy advertises this tool.
37    pub remote_present: bool,
38    /// Only set when both sides are present. `Some(true)` means the local schema can
39    /// satisfy every required argument the upstream schema declares.
40    pub schema_compatible: Option<bool>,
41    /// Prefix of the first upstream advertising this tool (used to build the fully
42    /// qualified remote name `{prefix}__{tool_name}`).
43    pub upstream_prefix: Option<String>,
44    /// Human-readable mismatch description. Always `Some` when `schema_compatible ==
45    /// Some(false)`; may also carry an advisory note when compatible but imperfect.
46    pub schema_mismatch: Option<String>,
47}
48
49impl ToolMatch {
50    /// Is there both a local and a remote implementation for this tool?
51    pub fn is_matched(&self) -> bool {
52        self.local_present && self.remote_present
53    }
54
55    /// Is the local executor a viable routing target for this tool?
56    pub fn is_routable_local(&self) -> bool {
57        self.is_matched() && self.schema_compatible.unwrap_or(false)
58    }
59
60    /// Fully qualified remote tool name (`{prefix}__{tool_name}`) if we know the prefix.
61    pub fn prefixed_remote_name(&self) -> Option<String> {
62        self.upstream_prefix
63            .as_ref()
64            .map(|p| format!("{}__{}", p, self.tool_name))
65    }
66}
67
68/// Index of every tool name encountered across local and upstream catalogues.
69#[derive(Debug, Clone, Default)]
70pub struct MatchReport {
71    pub matches: HashMap<String, ToolMatch>,
72}
73
74impl MatchReport {
75    /// Tools that have a viable local counterpart (matched + compatible schema).
76    pub fn routable_locally(&self) -> Vec<&ToolMatch> {
77        self.matches
78            .values()
79            .filter(|m| m.is_routable_local())
80            .collect()
81    }
82
83    /// Tools that exist only remotely.
84    pub fn remote_only(&self) -> Vec<&ToolMatch> {
85        self.matches
86            .values()
87            .filter(|m| m.remote_present && !m.local_present)
88            .collect()
89    }
90
91    /// Tools that exist only locally.
92    pub fn local_only(&self) -> Vec<&ToolMatch> {
93        self.matches
94            .values()
95            .filter(|m| m.local_present && !m.remote_present)
96            .collect()
97    }
98
99    /// Matched pairs where schemas disagree (compatible=false).
100    pub fn incompatible_pairs(&self) -> Vec<&ToolMatch> {
101        self.matches
102            .values()
103            .filter(|m| m.is_matched() && m.schema_compatible == Some(false))
104            .collect()
105    }
106
107    pub fn get(&self, tool_name: &str) -> Option<&ToolMatch> {
108        self.matches.get(tool_name)
109    }
110
111    pub fn len(&self) -> usize {
112        self.matches.len()
113    }
114
115    pub fn is_empty(&self) -> bool {
116        self.matches.is_empty()
117    }
118}
119
120/// Input for a matcher call — a local tool catalogue and one or more upstream catalogues.
121pub struct ToolCatalogue<'a> {
122    /// Local tool definitions with schemas (e.g., from `ToolHandler::available_tools()`).
123    pub local: &'a [ToolDefinition],
124    /// Upstream tool definitions per prefix. Each `(prefix, tools)` entry is one connected
125    /// upstream proxy; the tool definitions must be raw (unprefixed).
126    pub upstream: Vec<(String, &'a [ToolDefinition])>,
127}
128
129/// Build a match report by comparing the local and upstream tool catalogues.
130pub fn build_report(catalogue: ToolCatalogue<'_>) -> MatchReport {
131    let mut matches: HashMap<String, ToolMatch> = HashMap::new();
132
133    for tool in catalogue.local {
134        matches.insert(
135            tool.name.clone(),
136            ToolMatch {
137                tool_name: tool.name.clone(),
138                local_present: true,
139                remote_present: false,
140                schema_compatible: None,
141                upstream_prefix: None,
142                schema_mismatch: None,
143            },
144        );
145    }
146
147    for (prefix, upstream_tools) in &catalogue.upstream {
148        for up_tool in *upstream_tools {
149            let entry = matches
150                .entry(up_tool.name.clone())
151                .or_insert_with(|| ToolMatch {
152                    tool_name: up_tool.name.clone(),
153                    local_present: false,
154                    remote_present: false,
155                    schema_compatible: None,
156                    upstream_prefix: None,
157                    schema_mismatch: None,
158                });
159
160            entry.remote_present = true;
161            if entry.upstream_prefix.is_none() {
162                entry.upstream_prefix = Some(prefix.clone());
163            }
164
165            if entry.local_present
166                && let Some(local_tool) = catalogue.local.iter().find(|t| t.name == up_tool.name)
167            {
168                let check = check_schema_compat(&local_tool.input_schema, &up_tool.input_schema);
169                entry.schema_compatible = Some(check.is_compatible);
170                entry.schema_mismatch = check.reason;
171            }
172        }
173    }
174
175    MatchReport { matches }
176}
177
178#[derive(Debug, Clone)]
179struct SchemaCheck {
180    is_compatible: bool,
181    reason: Option<String>,
182}
183
184/// Conservative schema compatibility check.
185///
186/// Rules:
187/// - Every field `upstream.required` declares must also exist in `local.properties`.
188///   If missing, we mark incompatible (upstream arguments would be silently dropped).
189/// - Extra fields in either direction are permitted.
190/// - If the local schema declares a `required` field the upstream schema does not even
191///   describe, we still mark compatible but surface an advisory note — the local
192///   executor will enforce its own requirements at call time.
193///
194/// This intentionally ignores type checking for now. Structural type mismatches would
195/// manifest as runtime errors from the local executor; we rely on `fallback_on_error`
196/// to recover. A future revision can tighten this.
197fn check_schema_compat(local: &Value, remote: &Value) -> SchemaCheck {
198    let local_props = schema_properties(local);
199    let local_required = schema_required(local);
200    let remote_props = schema_properties(remote);
201    let remote_required = schema_required(remote);
202
203    for field in &remote_required {
204        if !local_props.contains_key(field) {
205            return SchemaCheck {
206                is_compatible: false,
207                reason: Some(format!(
208                    "upstream requires `{}` which local schema does not declare",
209                    field
210                )),
211            };
212        }
213    }
214
215    for field in &local_required {
216        if !remote_props.contains_key(field) && !remote_required.contains(field) {
217            return SchemaCheck {
218                is_compatible: true,
219                reason: Some(format!(
220                    "local requires `{}` which upstream schema does not describe; local enforcement still applies",
221                    field
222                )),
223            };
224        }
225    }
226
227    SchemaCheck {
228        is_compatible: true,
229        reason: None,
230    }
231}
232
233fn schema_properties(schema: &Value) -> HashMap<String, &Value> {
234    let Some(obj) = schema.as_object() else {
235        return HashMap::new();
236    };
237    let Some(props) = obj.get("properties").and_then(|v| v.as_object()) else {
238        return HashMap::new();
239    };
240    props.iter().map(|(k, v)| (k.clone(), v)).collect()
241}
242
243fn schema_required(schema: &Value) -> Vec<String> {
244    schema
245        .get("required")
246        .and_then(|v| v.as_array())
247        .map(|a| {
248            a.iter()
249                .filter_map(|v| v.as_str().map(String::from))
250                .collect()
251        })
252        .unwrap_or_default()
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use serde_json::json;
259
260    fn tool(name: &str, schema: Value) -> ToolDefinition {
261        ToolDefinition {
262            name: name.to_string(),
263            description: format!("tool {}", name),
264            input_schema: schema,
265            category: None,
266        }
267    }
268
269    fn empty_schema() -> Value {
270        json!({"type": "object", "properties": {}, "required": []})
271    }
272
273    #[test]
274    fn test_build_report_all_matched_same_schema() {
275        let local = vec![
276            tool("get_issues", empty_schema()),
277            tool("get_merge_requests", empty_schema()),
278        ];
279        let remote = vec![
280            tool("get_issues", empty_schema()),
281            tool("get_merge_requests", empty_schema()),
282        ];
283
284        let report = build_report(ToolCatalogue {
285            local: &local,
286            upstream: vec![("cloud".to_string(), &remote)],
287        });
288
289        assert_eq!(report.len(), 2);
290        let m = report.get("get_issues").unwrap();
291        assert!(m.is_matched());
292        assert_eq!(m.schema_compatible, Some(true));
293        assert_eq!(m.upstream_prefix.as_deref(), Some("cloud"));
294        assert_eq!(
295            m.prefixed_remote_name().as_deref(),
296            Some("cloud__get_issues")
297        );
298    }
299
300    #[test]
301    fn test_build_report_local_only() {
302        let local = vec![tool("list_contexts", empty_schema())];
303        let report = build_report(ToolCatalogue {
304            local: &local,
305            upstream: vec![("cloud".to_string(), &[])],
306        });
307
308        let m = report.get("list_contexts").unwrap();
309        assert!(m.local_present);
310        assert!(!m.remote_present);
311        assert!(!m.is_matched());
312    }
313
314    #[test]
315    fn test_build_report_remote_only() {
316        let remote = vec![tool("cloud_specific_tool", empty_schema())];
317        let report = build_report(ToolCatalogue {
318            local: &[],
319            upstream: vec![("cloud".to_string(), &remote)],
320        });
321
322        let m = report.get("cloud_specific_tool").unwrap();
323        assert!(m.remote_present);
324        assert!(!m.local_present);
325        assert_eq!(m.upstream_prefix.as_deref(), Some("cloud"));
326    }
327
328    #[test]
329    fn test_schema_compat_missing_required_field_is_incompatible() {
330        let local = vec![tool(
331            "get_issue",
332            json!({
333                "type": "object",
334                "properties": { "key": {"type": "string"} },
335                "required": ["key"]
336            }),
337        )];
338        let remote = vec![tool(
339            "get_issue",
340            json!({
341                "type": "object",
342                "properties": {
343                    "key": {"type": "string"},
344                    "workspace_id": {"type": "string"}
345                },
346                "required": ["key", "workspace_id"]
347            }),
348        )];
349
350        let report = build_report(ToolCatalogue {
351            local: &local,
352            upstream: vec![("cloud".to_string(), &remote)],
353        });
354
355        let m = report.get("get_issue").unwrap();
356        assert!(m.is_matched());
357        assert_eq!(m.schema_compatible, Some(false));
358        assert!(m.schema_mismatch.is_some());
359        assert!(!m.is_routable_local());
360    }
361
362    #[test]
363    fn test_schema_compat_extra_local_required_is_advisory_but_compatible() {
364        let local = vec![tool(
365            "get_issue",
366            json!({
367                "type": "object",
368                "properties": {
369                    "key": {"type": "string"},
370                    "workspace_id": {"type": "string"}
371                },
372                "required": ["key", "workspace_id"]
373            }),
374        )];
375        let remote = vec![tool(
376            "get_issue",
377            json!({
378                "type": "object",
379                "properties": { "key": {"type": "string"} },
380                "required": ["key"]
381            }),
382        )];
383
384        let report = build_report(ToolCatalogue {
385            local: &local,
386            upstream: vec![("cloud".to_string(), &remote)],
387        });
388
389        let m = report.get("get_issue").unwrap();
390        assert_eq!(m.schema_compatible, Some(true));
391        assert!(m.schema_mismatch.is_some());
392        assert!(m.is_routable_local());
393    }
394
395    #[test]
396    fn test_report_classification_helpers() {
397        let local = vec![
398            tool("local_only", empty_schema()),
399            tool("both_matched", empty_schema()),
400        ];
401        let remote = vec![
402            tool("remote_only", empty_schema()),
403            tool("both_matched", empty_schema()),
404        ];
405
406        let report = build_report(ToolCatalogue {
407            local: &local,
408            upstream: vec![("up".to_string(), &remote)],
409        });
410
411        let local_only: Vec<&str> = report
412            .local_only()
413            .iter()
414            .map(|m| m.tool_name.as_str())
415            .collect();
416        let remote_only: Vec<&str> = report
417            .remote_only()
418            .iter()
419            .map(|m| m.tool_name.as_str())
420            .collect();
421        let routable: Vec<&str> = report
422            .routable_locally()
423            .iter()
424            .map(|m| m.tool_name.as_str())
425            .collect();
426
427        assert_eq!(local_only, vec!["local_only"]);
428        assert_eq!(remote_only, vec!["remote_only"]);
429        assert_eq!(routable, vec!["both_matched"]);
430    }
431
432    #[test]
433    fn test_first_upstream_wins_prefix_when_multiple_advertise_same_tool() {
434        let a = vec![tool("shared", empty_schema())];
435        let b = vec![tool("shared", empty_schema())];
436
437        let report = build_report(ToolCatalogue {
438            local: &[],
439            upstream: vec![("cloudA".to_string(), &a), ("cloudB".to_string(), &b)],
440        });
441
442        assert_eq!(
443            report.get("shared").unwrap().upstream_prefix.as_deref(),
444            Some("cloudA")
445        );
446    }
447}