Skip to main content

dnslib/mcp/tools/
sync.rs

1use rmcp::{ErrorData as McpError, model::*};
2
3use crate::{
4    control_plane::{config::AppConfig, policy::Policy},
5    mcp::{helpers::run_json, params::SyncParams},
6};
7
8pub async fn handle_sync(
9    config: &AppConfig,
10    from_policy: &Policy,
11    to_policy: &Policy,
12    p: SyncParams,
13) -> Result<CallToolResult, McpError> {
14    let profile_zones = p
15        .profile
16        .as_deref()
17        .and_then(|name| {
18            config
19                .sync
20                .iter()
21                .find(|profile| profile.name.eq_ignore_ascii_case(name))
22        })
23        .map(|profile| profile.zones.as_slice())
24        .unwrap_or(&[]);
25    let effective_zones = if p.zones.is_empty() {
26        profile_zones
27    } else {
28        p.zones.as_slice()
29    };
30    let zone_check = if effective_zones.is_empty()
31        && (from_policy.allowed_zones.is_some() || to_policy.allowed_zones.is_some())
32    {
33        Err(crate::core::error::Error::policy_violation(
34            "MCP sync with zone allowlists requires explicit zones",
35            "Pass `zones` in the tool call or configure zones on the selected sync profile.",
36        ))
37    } else {
38        effective_zones
39            .iter()
40            .try_for_each(|zone| from_policy.check_zone(zone).and(to_policy.check_zone(zone)))
41    };
42    let check = from_policy
43        .check_read()
44        .and(to_policy.check_write())
45        .and(zone_check);
46
47    Ok(run_json("dns_sync", check, async move {
48        crate::control_plane::sync::run_sync_json(
49            Some(config),
50            p.profile.as_deref(),
51            p.from.as_deref(),
52            p.to.as_deref(),
53            &p.zones,
54            &p.map,
55            p.apply,
56        )
57        .await
58    })
59    .await)
60}
61
62#[cfg(test)]
63mod tests {
64    use serde_json::Value;
65
66    use super::*;
67    use crate::control_plane::policy::PolicyRule;
68
69    #[tokio::test]
70    async fn restricted_sync_requires_explicit_zones() {
71        let config = AppConfig::default();
72        let from_policy = Policy::new([PolicyRule::Read], Some(vec!["example.com".to_string()]));
73        let to_policy = Policy::new([PolicyRule::Write], None);
74
75        let result = handle_sync(
76            &config,
77            &from_policy,
78            &to_policy,
79            SyncParams {
80                profile: None,
81                from: Some("from".to_string()),
82                to: Some("to".to_string()),
83                zones: Vec::new(),
84                map: Vec::new(),
85                apply: false,
86            },
87        )
88        .await
89        .unwrap();
90
91        assert_eq!(result.is_error, Some(true));
92        let text = result.content[0]
93            .as_text()
94            .expect("policy denial should be returned as text JSON");
95        let value: Value = serde_json::from_str(&text.text).unwrap();
96        assert!(
97            value["error"]
98                .as_str()
99                .unwrap()
100                .contains("requires explicit zones")
101        );
102    }
103}