Skip to main content

dnslib/mcp/tools/
resolve.rs

1//! `dns_resolve` MCP tool — the MCP-side of the `dns query` subcommand.
2//!
3//! Maintains CLI/MCP parity (agents.md §"CLI and MCP capability must
4//! stay in parity"): both surfaces share the same `execute_query`
5//! engine and return identical JSON shapes.
6
7use rmcp::{ErrorData as McpError, model::*};
8
9use crate::{
10    cli::query::{QueryArgs, execute_query},
11    control_plane::{
12        config::AppConfig,
13        policy::{Policy, PolicyRule},
14    },
15    core::error::Error,
16    mcp::{helpers::mcp_err, params::ResolveParams},
17};
18
19pub async fn handle_resolve(
20    config: &AppConfig,
21    cli_access: &[PolicyRule],
22    cli_allow_zone: &[String],
23    p: ResolveParams,
24) -> Result<CallToolResult, McpError> {
25    tracing::info!(tool = "dns_resolve", "MCP tool invoked");
26
27    // When the request targets a configured `[[servers]]` entry, gate
28    // it on that server's MCP read permission. Ad-hoc and system
29    // resolver paths are not vendor-API operations and aren't covered
30    // by per-server access controls; they pass through.
31    if let Some(ref server_id) = p.server_id
32        && let Ok(server) = config.selected_server(Some(server_id))
33    {
34        let policy = Policy::for_server(server, cli_access, cli_allow_zone).map_err(mcp_err)?;
35        policy.check_read().map_err(mcp_err)?;
36    }
37
38    let args = params_to_args(p).map_err(mcp_err)?;
39    let outcome = execute_query(Some(config.clone()), args)
40        .await
41        .map_err(mcp_err)?;
42
43    Ok(crate::mcp::helpers::json_result(outcome.to_json()))
44}
45
46fn params_to_args(p: ResolveParams) -> Result<QueryArgs, Error> {
47    let transports = p.transports.unwrap_or_default();
48    let mut args = QueryArgs {
49        targets: vec![p.domain],
50        r#type: p.types.unwrap_or_default(),
51        server: p.server_id,
52        at: p.at,
53        port: p.port,
54        tls_server_name: p.tls_server_name,
55        timeout: p.timeout_ms,
56        all: p.all_transports.unwrap_or(false),
57        json: true,
58        ..Default::default()
59    };
60    for transport in transports {
61        match transport.to_ascii_lowercase().as_str() {
62            "dns" => args.dns = true,
63            "dot" => args.dot = true,
64            "doh" => args.doh = true,
65            "doq" => args.doq = true,
66            other => {
67                return Err(Error::parse(format!(
68                    "unknown transport '{other}' in `transports`; expected one of dns/dot/doh/doq",
69                )));
70            }
71        }
72    }
73    Ok(args)
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn params_to_args_maps_transport_strings() {
82        let p = ResolveParams {
83            domain: "example.com".into(),
84            types: Some(vec!["A".into(), "AAAA".into()]),
85            server_id: Some("dns1".into()),
86            at: None,
87            transports: Some(vec!["dot".into(), "doh".into()]),
88            all_transports: None,
89            port: None,
90            tls_server_name: None,
91            timeout_ms: Some(1500),
92        };
93        let args = params_to_args(p).unwrap();
94        assert_eq!(args.targets, vec!["example.com".to_string()]);
95        assert_eq!(args.r#type, vec!["A".to_string(), "AAAA".to_string()]);
96        assert_eq!(args.server.as_deref(), Some("dns1"));
97        assert!(args.dot);
98        assert!(args.doh);
99        assert!(!args.dns);
100        assert!(!args.doq);
101        assert!(!args.all);
102        assert_eq!(args.timeout, Some(1500));
103        // MCP always emits JSON
104        assert!(args.json);
105    }
106
107    #[test]
108    fn params_to_args_all_transports() {
109        let p = ResolveParams {
110            domain: "example.com".into(),
111            types: None,
112            server_id: Some("dns1".into()),
113            at: None,
114            transports: None,
115            all_transports: Some(true),
116            port: None,
117            tls_server_name: None,
118            timeout_ms: None,
119        };
120        let args = params_to_args(p).unwrap();
121        assert!(args.all);
122    }
123
124    #[test]
125    fn params_to_args_rejects_unknown_transport() {
126        let p = ResolveParams {
127            domain: "example.com".into(),
128            types: None,
129            server_id: None,
130            at: Some("1.1.1.1".into()),
131            transports: Some(vec!["smtp".into()]),
132            all_transports: None,
133            port: None,
134            tls_server_name: None,
135            timeout_ms: None,
136        };
137        assert!(params_to_args(p).is_err());
138    }
139}