dnslib/mcp/server.rs
1use std::sync::Arc;
2
3use rmcp::{
4 ErrorData as McpError, ServerHandler,
5 handler::server::{router::tool::ToolRouter, wrapper::Parameters},
6 model::*,
7 tool, tool_handler, tool_router,
8};
9
10use crate::{
11 control_plane::{
12 config::AppConfig,
13 policy::{Policy, PolicyRule},
14 transfer,
15 },
16 mcp::{
17 helpers::mcp_err,
18 params::*,
19 tools::{
20 access_lists, cache as cache_tools, logs as logs_tools, records as record_tools,
21 resolve as resolve_tools, settings as settings_tools, stats as stats_tools,
22 sync as sync_tools, zones as zone_tools,
23 },
24 },
25 vendors::runtime::VendorClient,
26};
27
28// ─── Server state ─────────────────────────────────────────────────────────────
29
30#[derive(Clone)]
31pub struct DnsServer {
32 config: Arc<AppConfig>,
33 cli_access: Arc<Vec<PolicyRule>>,
34 cli_allow_zone: Arc<Vec<String>>,
35 startup_info: String,
36 #[allow(dead_code)]
37 tool_router: ToolRouter<Self>,
38}
39
40impl DnsServer {
41 /// Construct a `DnsServer` from the given application configuration and CLI-derived policy inputs.
42 ///
43 /// The created server stores the provided `config`, `cli_access`, and `cli_allow_zone` (each wrapped in `Arc`)
44 /// and computes a human-readable `startup_info` message that either lists available server IDs or instructs
45 /// how to add a server when none are configured.
46 ///
47 /// # Examples
48 ///
49 /// ```ignore
50 /// // Create a DnsServer from an AppConfig and CLI policy inputs.
51 /// // (Fields shown here are illustrative; construct AppConfig/PolicyRule as appropriate in real code.)
52 /// let config = AppConfig { servers: vec![] }; // or whatever constructor is available
53 /// let cli_access = Vec::<PolicyRule>::new();
54 /// let cli_allow_zone = Vec::<String>::new();
55 /// let server = DnsServer::new(config, cli_access, cli_allow_zone);
56 /// ```
57 pub fn new(
58 config: AppConfig,
59 cli_access: Vec<PolicyRule>,
60 cli_allow_zone: Vec<String>,
61 ) -> Self {
62 let startup_info = if config.servers.is_empty() {
63 " No DNS servers configured. Run `dns config add` to add one, then restart the MCP server.".to_string()
64 } else {
65 let ids: Vec<&str> = config.servers.iter().map(|s| s.id.as_str()).collect();
66 format!(
67 " Available servers: {}. Pass `server_id` to every tool.",
68 ids.join(", ")
69 )
70 };
71
72 Self {
73 config: Arc::new(config),
74 cli_access: Arc::new(cli_access),
75 cli_allow_zone: Arc::new(cli_allow_zone),
76 startup_info,
77 tool_router: Self::tool_router(),
78 }
79 }
80
81 /// Resolve a configured DNS backend by its identifier and produce a client and policy for calling it.
82 ///
83 /// Looks up `server_id` case-insensitively in the server list, constructs a `VendorClient` for that
84 /// server, and builds a `Policy` using the CLI-provided access and allow-zone rules. If the server
85 /// cannot be found, returns a configuration error advising the caller to list available server IDs.
86 ///
87 /// # Parameters
88 ///
89 /// - `server_id`: Case-insensitive identifier of the configured server to resolve.
90 ///
91 /// # Returns
92 ///
93 /// A `(VendorClient, Policy)` pair for the matched server.
94 ///
95 /// # Errors
96 ///
97 /// Returns a configuration `Error` if no server with the given `server_id` exists.
98 ///
99 /// # Examples
100 ///
101 /// ```ignore
102 /// # use std::sync::Arc;
103 /// # use crate::mcp::server::DnsServer;
104 /// # use crate::config::AppConfig;
105 /// // Given a DnsServer `srv` and a server id:
106 /// // let srv = DnsServer::new(app_config, vec![], vec![]);
107 /// // let (client, policy) = srv.resolve_server("primary")?;
108 /// ```
109 fn resolve_server(
110 &self,
111 server_id: &str,
112 ) -> crate::core::error::Result<(VendorClient, Policy)> {
113 let server = self
114 .config
115 .servers
116 .iter()
117 .find(|s| s.id.eq_ignore_ascii_case(server_id))
118 .ok_or_else(|| {
119 crate::core::error::Error::config(format!(
120 "no server named '{server_id}' — call dns_list_servers to see available IDs"
121 ))
122 })?;
123 let client = VendorClient::from_server(server)?;
124 let policy = Policy::for_server(server, &self.cli_access, &self.cli_allow_zone)?;
125 Ok((client, policy))
126 }
127
128 fn show_settings_secrets(&self, server_id: &str) -> crate::core::error::Result<bool> {
129 self.config
130 .servers
131 .iter()
132 .find(|s| s.id.eq_ignore_ascii_case(server_id))
133 .map(|server| server.mcp.show_settings_secrets)
134 .ok_or_else(|| {
135 crate::core::error::Error::config(format!(
136 "no server named '{server_id}' — call dns_list_servers to see available IDs"
137 ))
138 })
139 }
140}
141
142// ─── Tools ───────────────────────────────────────────────────────────────────
143
144#[tool_router]
145impl DnsServer {
146 // ── Server management ─────────────────────────────────────────────────
147
148 /// List configured DNS servers with their `id`, `vendor`, and `base_url`.
149 ///
150 /// Returns a JSON object with a `servers` array where each element is an object containing:
151 /// - `id`: the server identifier
152 /// - `vendor`: the vendor name formatted with `Debug`
153 /// - `base_url`: the server base URL, or the string `"(default)"` when no base URL is configured
154 ///
155 /// # Examples
156 ///
157 /// ```ignore
158 /// use serde_json::json;
159 ///
160 /// let expected_shape = json!({
161 /// "servers": [ { "id": "example", "vendor": "VendorA", "base_url": "(default)" } ]
162 /// });
163 ///
164 /// assert!(expected_shape.get("servers").is_some());
165 /// ```
166 #[tool(description = "List all DNS servers defined in the config file. \
167 Shows each server's ID, vendor, and base URL. \
168 Call this first to discover server IDs — pass `server_id` to every other tool.")]
169 async fn dns_list_servers(&self) -> Result<CallToolResult, McpError> {
170 tracing::info!(tool = "dns_list_servers", "MCP tool invoked");
171
172 let servers: Vec<serde_json::Value> = self
173 .config
174 .servers
175 .iter()
176 .map(|s| {
177 serde_json::json!({
178 "id": s.id,
179 "vendor": format!("{:?}", s.vendor),
180 "base_url": s.base_url.as_deref().unwrap_or("(default)"),
181 })
182 })
183 .collect();
184
185 Ok(crate::mcp::helpers::json_result(serde_json::json!({
186 "servers": servers,
187 })))
188 }
189
190 // ── Zones ─────────────────────────────────────────────────────────────
191
192 /// List authoritative zones hosted on the specified DNS server.
193 ///
194 /// The `server_id` field of the provided parameters selects which configured backend to query.
195 ///
196 /// # Returns
197 ///
198 /// `CallToolResult` containing a JSON object with the zones list on success, or an `McpError` on failure.
199 ///
200 /// # Examples
201 ///
202 /// ```ignore
203 /// # async fn example(server: &crate::mcp::DnsServer) -> Result<(), crate::core::error::McpError> {
204 /// let params = crate::mcp::ListZonesParams { server_id: "primary".into(), ..Default::default() };
205 /// let result = server.dns_list_zones(crate::mcp::Parameters(params)).await?;
206 /// println!("{:?}", result);
207 /// # Ok(())
208 /// # }
209 /// ```
210 #[tool(
211 description = "List all authoritative zones hosted on the DNS server. \
212 Use `server_id` from dns_list_servers."
213 )]
214 async fn dns_list_zones(
215 &self,
216 Parameters(p): Parameters<ListZonesParams>,
217 ) -> Result<CallToolResult, McpError> {
218 tracing::info!(tool = "dns_list_zones", "MCP tool invoked");
219 let (client, policy) = self.resolve_server(&p.server_id).map_err(mcp_err)?;
220 zone_tools::handle_list_zones(&client, &policy, p).await
221 }
222
223 /// Create a new DNS zone on a configured server.
224 ///
225 /// Supports zone types: Primary, Secondary, Stub, and Forwarder. The `server_id` field in the parameters
226 /// must reference one of the configured servers (discoverable via `dns_list_servers`).
227 ///
228 /// # Parameters
229 ///
230 /// - `p`: `CreateZoneParams` containing the zone definition and the target `server_id`.
231 ///
232 /// # Returns
233 ///
234 /// `CallToolResult` describing the outcome of the creation operation.
235 ///
236 /// # Examples
237 ///
238 /// ```ignore
239 /// # async fn example(dns_server: &crate::mcp::server::DnsServer) -> Result<(), crate::core::error::McpError> {
240 /// let params = crate::mcp::tools::zones::CreateZoneParams {
241 /// server_id: "primary-1".into(),
242 /// zone: "example.com".into(),
243 /// zone_type: Some("Primary".into()),
244 /// // ... other fields ...
245 /// };
246 /// let result = dns_server.dns_create_zone(crate::mcp::tools::Parameters(params)).await?;
247 /// # Ok(())
248 /// # }
249 /// ```
250 #[tool(
251 description = "Create a new DNS zone. Types: Primary, Secondary, Stub, Forwarder. \
252 Use `server_id` from dns_list_servers."
253 )]
254 async fn dns_create_zone(
255 &self,
256 Parameters(p): Parameters<CreateZoneParams>,
257 ) -> Result<CallToolResult, McpError> {
258 tracing::info!(tool = "dns_create_zone", "MCP tool invoked");
259 tracing::debug!(zone = %p.zone, "tool invoked");
260 let (client, policy) = self.resolve_server(&p.server_id).map_err(mcp_err)?;
261 zone_tools::handle_create_zone(&client, &policy, p).await
262 }
263
264 /// Deletes the specified DNS zone on the configured backend server.
265 ///
266 /// The `p` parameter must include `server_id` to select a configured DNS backend and `zone` to
267 /// identify the zone to remove. This operation is destructive and cannot be undone.
268 ///
269 /// # Parameters
270 ///
271 /// - `p`: `ZoneParams` containing `server_id` and the `zone` name to delete.
272 ///
273 /// # Returns
274 ///
275 /// `Ok(CallToolResult)` on success, `Err(McpError)` if the server cannot be resolved or deletion fails.
276 ///
277 /// # Examples
278 ///
279 /// ```ignore
280 /// # async fn example(server: &crate::mcp::server::DnsServer) -> Result<(), crate::mcp::error::McpError> {
281 /// use crate::mcp::params::ZoneParams;
282 /// use crate::mcp::server::Parameters;
283 ///
284 /// let params = ZoneParams { server_id: "primary".into(), zone: "example.com".into() };
285 /// let _result = server.dns_delete_zone(Parameters(params)).await?;
286 /// # Ok(())
287 /// # }
288 /// ```
289 #[tool(
290 description = "Delete a DNS zone. This is destructive and cannot be undone. \
291 Use `server_id` from dns_list_servers."
292 )]
293 async fn dns_delete_zone(
294 &self,
295 Parameters(p): Parameters<ZoneParams>,
296 ) -> Result<CallToolResult, McpError> {
297 tracing::info!(tool = "dns_delete_zone", "MCP tool invoked");
298 tracing::debug!(zone = %p.zone, "tool invoked");
299 let (client, policy) = self.resolve_server(&p.server_id).map_err(mcp_err)?;
300 zone_tools::handle_delete_zone(&client, &policy, p).await
301 }
302
303 /// Enables a previously disabled DNS zone on the specified server.
304 ///
305 /// The `server_id` must match one of the IDs returned by `dns_list_servers`.
306 ///
307 /// # Examples
308 ///
309 /// ```ignore
310 /// // Example usage (async context):
311 /// // dns_enable_zone(&server, Parameters(ZoneParams { server_id: "prod-dns".into(), zone: "example.com".into() })).await?;
312 /// ```
313 #[tool(description = "Enable a previously disabled DNS zone. \
314 Use `server_id` from dns_list_servers.")]
315 async fn dns_enable_zone(
316 &self,
317 Parameters(p): Parameters<ZoneParams>,
318 ) -> Result<CallToolResult, McpError> {
319 tracing::info!(tool = "dns_enable_zone", "MCP tool invoked");
320 tracing::debug!(zone = %p.zone, "tool invoked");
321 let (client, policy) = self.resolve_server(&p.server_id).map_err(mcp_err)?;
322 zone_tools::handle_enable_zone(&client, &policy, p).await
323 }
324
325 /// Disable a DNS zone so it stops responding to queries.
326 ///
327 /// The `server_id` field in `ZoneParams` selects which configured backend to operate on;
328 /// discover valid IDs with `dns_list_servers`.
329 ///
330 /// # Examples
331 ///
332 /// ```ignore
333 /// // Disable zone "example.com" on server "primary"
334 /// let params = ZoneParams { server_id: "primary".into(), zone: "example.com".into() };
335 /// let _res = dns_server.dns_disable_zone(Parameters(params)).await;
336 /// ```
337 ///
338 /// Returns `Ok(CallToolResult)` on success, `Err(McpError)` on failure.
339 #[tool(description = "Disable a DNS zone so it stops responding to queries. \
340 Use `server_id` from dns_list_servers.")]
341 async fn dns_disable_zone(
342 &self,
343 Parameters(p): Parameters<ZoneParams>,
344 ) -> Result<CallToolResult, McpError> {
345 tracing::info!(tool = "dns_disable_zone", "MCP tool invoked");
346 tracing::debug!(zone = %p.zone, "tool invoked");
347 let (client, policy) = self.resolve_server(&p.server_id).map_err(mcp_err)?;
348 zone_tools::handle_disable_zone(&client, &policy, p).await
349 }
350
351 /// Imports an RFC 1035 zone file into an existing zone.
352 ///
353 /// This replaces or merges the zone's records according to the `overwrite_zone` flag:
354 /// - If `overwrite_zone` is `true`, existing records for the zone are deleted before import.
355 /// - If `overwrite_zone` is `false`, records from the zone file are added/updated alongside existing records.
356 ///
357 /// The `server_id` parameter selects which configured DNS backend to target (see `dns_list_servers`).
358 ///
359 /// # Examples
360 ///
361 /// ```ignore
362 /// use crate::mcp::params::ImportZoneFileParams;
363 /// // construct params with `zone`, `content`, `overwrite_zone`, and `server_id`
364 /// let params = ImportZoneFileParams { zone: "example.com".into(), content: "$ORIGIN example.com.\n...".into(), overwrite_zone: true, server_id: "primary".into() };
365 /// // `srv` is a `DnsServer` instance; call from an async context
366 /// let _res = tokio::runtime::Runtime::new().unwrap().block_on(async { srv.dns_import_zone_file(Parameters(params)).await });
367 /// ```
368 #[tool(
369 description = "Import a zone file (RFC 1035 format) into an existing zone. \
370 Pass the full zone file text in `content`. Use `overwrite_zone: true` for a clean \
371 replace that deletes all existing records first. \
372 Use `server_id` from dns_list_servers."
373 )]
374 async fn dns_import_zone_file(
375 &self,
376 Parameters(p): Parameters<ImportZoneFileParams>,
377 ) -> Result<CallToolResult, McpError> {
378 tracing::info!(tool = "dns_import_zone_file", "MCP tool invoked");
379 tracing::debug!(zone = %p.zone, "tool invoked");
380 let (client, policy) = self.resolve_server(&p.server_id).map_err(mcp_err)?;
381 zone_tools::handle_import_zone_file(&client, &policy, p).await
382 }
383
384 /// Export a DNS zone in BIND (RFC 1035) zone file format.
385 ///
386 /// The specified `server_id` selects which configured DNS backend to query; use `dns_list_servers` to discover available IDs.
387 /// The returned result contains the complete zone file text suitable for saving to disk or importing into another DNS provider.
388 ///
389 /// # Examples
390 ///
391 /// ```ignore
392 /// # async fn example(server: &crate::mcp::server::DnsServer) -> Result<(), crate::core::error::McpError> {
393 /// use crate::mcp::tools::Parameters;
394 /// use crate::mcp::params::ExportZoneFileParams;
395 ///
396 /// let params = ExportZoneFileParams { server_id: "primary".into(), zone: "example.com".into() };
397 /// let res = server.dns_export_zone_file(Parameters(params)).await?;
398 /// // `res` contains the exported zone file text (BIND format)
399 /// # Ok(())
400 /// # }
401 /// ```
402 #[tool(
403 description = "Export a DNS zone as a BIND-format (RFC 1035) zone file. \
404 Returns the full zone file text, which can be saved to disk or imported into another DNS provider. \
405 Use `server_id` from dns_list_servers."
406 )]
407 async fn dns_export_zone_file(
408 &self,
409 Parameters(p): Parameters<ExportZoneFileParams>,
410 ) -> Result<CallToolResult, McpError> {
411 tracing::info!(tool = "dns_export_zone_file", "MCP tool invoked");
412 tracing::debug!(zone = %p.zone, "tool invoked");
413 let (client, policy) = self.resolve_server(&p.server_id).map_err(mcp_err)?;
414 zone_tools::handle_export_zone_file(&client, &policy, p).await
415 }
416
417 #[tool(description = "Copy a zone from one configured server to another. \
418 Reads from `from`, writes to `to`, and respects each server's MCP permissions and allowed zones.")]
419 async fn dns_transfer_zone(
420 &self,
421 Parameters(p): Parameters<TransferZoneParams>,
422 ) -> Result<CallToolResult, McpError> {
423 tracing::info!(tool = "dns_transfer_zone", "MCP tool invoked");
424 let (_, from_policy) = self.resolve_server(&p.from).map_err(mcp_err)?;
425 let (_, to_policy) = self.resolve_server(&p.to).map_err(mcp_err)?;
426 let check = from_policy
427 .check_read()
428 .and(from_policy.check_zone(&p.zone))
429 .and(to_policy.check_write())
430 .and(to_policy.check_zone(&p.zone));
431 Ok(
432 crate::mcp::helpers::run_json("dns_transfer_zone", check, async move {
433 let result = transfer::transfer_zone(
434 Some(&self.config),
435 &p.zone,
436 &p.from,
437 &p.to,
438 p.overwrite,
439 p.overwrite_zone,
440 )
441 .await?;
442 serde_json::to_value(result).map_err(|e| {
443 crate::core::error::Error::parse(format!(
444 "could not serialise zone transfer result: {e}"
445 ))
446 })
447 })
448 .await,
449 )
450 }
451
452 // ── Records ───────────────────────────────────────────────────────────
453
454 /// List DNS records for a domain, returning typed records including writable and DNSSEC types.
455 ///
456 /// Returns a JSON result containing the domain's DNS records suitable for display and editing.
457 /// The returned set includes writable record types (for example: `A`, `AAAA`, `MX`, etc.)
458 /// and read-only DNSSEC records (`DNSKEY`, `RRSIG`, `NSEC`, `NSEC3`).
459 ///
460 /// # Examples
461 ///
462 /// ```ignore
463 /// // Example (pseudo): call the tool with a server_id and domain.
464 /// // let srv = DnsServer::new(...);
465 /// // let params = ListRecordsParams { server_id: "primary".into(), domain: "example.com".into(), zone: None };
466 /// // let result = srv.dns_list_records(Parameters(params)).await?;
467 /// ```
468 #[tool(
469 description = "List all DNS records for a domain. Returns typed records including writable types (A, AAAA, MX, etc.) and read-only DNSSEC types (DNSKEY, RRSIG, NSEC, NSEC3). \
470 Use `server_id` from dns_list_servers."
471 )]
472 async fn dns_list_records(
473 &self,
474 Parameters(p): Parameters<ListRecordsParams>,
475 ) -> Result<CallToolResult, McpError> {
476 tracing::info!(tool = "dns_list_records", "MCP tool invoked");
477 tracing::debug!(domain = ?p.domain, zone = ?p.zone, "tool invoked");
478 let (client, policy) = self.resolve_server(&p.server_id).map_err(mcp_err)?;
479 record_tools::handle_list_records(&client, &policy, p).await
480 }
481
482 /// Adds a DNS record to a zone on the specified server.
483 ///
484 /// The operation applies the provided `record` (typed union: e.g. `A`, `MX`, `TXT`) to `zone`/`domain` on the server identified by `server_id`.
485 ///
486 /// # Examples
487 ///
488 /// ```ignore
489 /// # use crate::mcp::server::DnsServer;
490 /// # use crate::mcp::params::{AddRecordParams, Record};
491 /// # use crate::mcp::tools::Parameters;
492 /// # async fn _example(server: &DnsServer) {
493 /// let params = AddRecordParams {
494 /// server_id: "primary".to_string(),
495 /// zone: "example.com".to_string(),
496 /// domain: "www".to_string(),
497 /// record: Record::A { ip: "1.2.3.4".to_string() },
498 /// };
499 /// let result = server.dns_add_record(Parameters(params)).await;
500 /// assert!(result.is_ok());
501 /// # }
502 /// ```
503 #[tool(
504 description = "Add a DNS record. The `record` field is a typed union: {\"type\":\"A\",\"ip\":\"1.2.3.4\"}, {\"type\":\"MX\",\"exchange\":\"mail.example.com\",\"preference\":10}, {\"type\":\"TXT\",\"text\":\"...\"}, etc. \
505 Use `server_id` from dns_list_servers."
506 )]
507 async fn dns_add_record(
508 &self,
509 Parameters(p): Parameters<AddRecordParams>,
510 ) -> Result<CallToolResult, McpError> {
511 tracing::info!(tool = "dns_add_record", "MCP tool invoked");
512 tracing::debug!(zone = %p.zone, domain = %p.domain, "tool invoked");
513 let (client, policy) = self.resolve_server(&p.server_id).map_err(mcp_err)?;
514 record_tools::handle_add_record(&client, &policy, p).await
515 }
516
517 /// Delete one or more DNS records for a configured server.
518 ///
519 /// The `server_id` field in the parameters selects which configured backend to use (see `dns_list_servers`).
520 /// If only `type` is provided, all records of that type for the specified domain are deleted; providing value fields
521 /// (for example an IP address for an A record) narrows the deletion to matching records.
522 ///
523 /// # Examples
524 ///
525 /// ```ignore
526 /// # async fn example(srv: &DnsServer) {
527 /// let params = DeleteRecordParams {
528 /// server_id: "primary".into(),
529 /// zone: "example.com".into(),
530 /// domain: "www".into(),
531 /// r#type: "A".into(),
532 /// ip_address: Some("1.2.3.4".into()),
533 /// ..Default::default()
534 /// };
535 /// let res = srv.dns_delete_record(Parameters(params)).await;
536 /// assert!(res.is_ok());
537 /// # }
538 /// ```
539 #[tool(
540 description = "Delete DNS record(s). Only `type` is required \u{2014} omitting value fields \
541 deletes ALL records of that type for the domain. \
542 e.g. {\"type\":\"A\"} deletes all A records; {\"type\":\"A\",\"ipAddress\":\"1.2.3.4\"} deletes one specific record. \
543 Use `server_id` from dns_list_servers."
544 )]
545 async fn dns_delete_record(
546 &self,
547 Parameters(p): Parameters<DeleteRecordParams>,
548 ) -> Result<CallToolResult, McpError> {
549 tracing::info!(tool = "dns_delete_record", "MCP tool invoked");
550 tracing::debug!(zone = %p.zone, domain = %p.domain, "tool invoked");
551 let (client, policy) = self.resolve_server(&p.server_id).map_err(mcp_err)?;
552 record_tools::handle_delete_record(&client, &policy, p).await
553 }
554
555 // ── Cache ─────────────────────────────────────────────────────────────
556
557 /// List entries in the DNS cache for a specific configured server and domain.
558 ///
559 /// If the `domain` parameter is an empty string, the root cache is listed. Use a `server_id` obtained from `dns_list_servers`.
560 ///
561 /// # Parameters
562 ///
563 /// - `p`: Parameters containing `server_id` (the configured server to query) and `domain` (the domain to list).
564 ///
565 /// # Returns
566 ///
567 /// `CallToolResult` containing the cache entries for the specified server and domain, or an `McpError` on failure.
568 ///
569 /// # Examples
570 ///
571 /// ```ignore
572 /// // Async context required:
573 /// // let params = Parameters(DomainParams { server_id: "primary".into(), domain: "".into() });
574 /// // let result = dns_server.dns_list_cache(params).await?;
575 /// ```
576 #[tool(
577 description = "Browse the DNS cache. Pass an empty string for domain to list the root. \
578 Use `server_id` from dns_list_servers."
579 )]
580 async fn dns_list_cache(
581 &self,
582 Parameters(p): Parameters<DomainParams>,
583 ) -> Result<CallToolResult, McpError> {
584 tracing::info!(tool = "dns_list_cache", "MCP tool invoked");
585 tracing::debug!(domain = %p.domain, "tool invoked");
586 let (client, policy) = self.resolve_server(&p.server_id).map_err(mcp_err)?;
587 cache_tools::handle_list_cache(&client, &policy, p).await
588 }
589
590 /// Evicts the DNS cache for a specific domain on the targeted server.
591 ///
592 /// The `server_id` field in the parameters selects which configured DNS backend to operate on;
593 /// discover available IDs with `dns_list_servers`.
594 ///
595 /// # Returns
596 ///
597 /// `CallToolResult` on success, `McpError` on failure.
598 ///
599 /// # Examples
600 ///
601 /// ```ignore
602 /// # use crate::mcp::server::DnsServer;
603 /// # use crate::mcp::params::DomainParams;
604 /// # use crate::mcp::tools::Parameters;
605 /// # async fn example(server: &DnsServer) {
606 /// let params = DomainParams { server_id: "primary".into(), domain: "example.com".into() };
607 /// let res = server.dns_delete_cache_zone(Parameters(params)).await;
608 /// match res {
609 /// Ok(result) => println!("Evicted cache: {:?}", result),
610 /// Err(err) => eprintln!("Failed to evict cache: {:?}", err),
611 /// }
612 /// # }
613 /// ```
614 #[tool(description = "Evict a specific domain from the DNS cache. \
615 Use `server_id` from dns_list_servers.")]
616 async fn dns_delete_cache_zone(
617 &self,
618 Parameters(p): Parameters<DomainParams>,
619 ) -> Result<CallToolResult, McpError> {
620 tracing::info!(tool = "dns_delete_cache_zone", "MCP tool invoked");
621 tracing::debug!(domain = %p.domain, "tool invoked");
622 let (client, policy) = self.resolve_server(&p.server_id).map_err(mcp_err)?;
623 cache_tools::handle_delete_cache_zone(&client, &policy, p).await
624 }
625
626 /// Flushes the DNS cache for the configured server identified by `server_id`.
627 ///
628 /// Use a `server_id` returned by `dns_list_servers` to target the correct backend.
629 ///
630 /// Returns a `CallToolResult` on success, or an `McpError` if the server cannot be resolved or the flush fails.
631 ///
632 /// # Examples
633 ///
634 /// ```ignore
635 /// # use crate::mcp::server::DnsServer;
636 /// # use crate::mcp::params::ServerScopeParams;
637 /// # async fn example(server: &DnsServer) -> Result<(), crate::core::error::McpError> {
638 /// let params = ServerScopeParams { server_id: "main".to_string() };
639 /// let result = server.dns_flush_cache(crate::mcp::tools::Parameters(params)).await?;
640 /// # Ok(())
641 /// # }
642 /// ```
643 #[tool(
644 description = "Flush the entire DNS cache, forcing all records to be resolved fresh. \
645 Use `server_id` from dns_list_servers."
646 )]
647 async fn dns_flush_cache(
648 &self,
649 Parameters(p): Parameters<ServerScopeParams>,
650 ) -> Result<CallToolResult, McpError> {
651 tracing::info!(tool = "dns_flush_cache", "MCP tool invoked");
652 let (client, policy) = self.resolve_server(&p.server_id).map_err(mcp_err)?;
653 cache_tools::handle_flush_cache(&client, &policy).await
654 }
655
656 // ── Stats ─────────────────────────────────────────────────────────────
657
658 /// Fetches dashboard statistics for a configured DNS server.
659 ///
660 /// Requests metrics for the server identified by `server_id`. The `stats_type` selects the
661 /// time range and defaults to `"LastDay"` when unset. Valid `stats_type` values are
662 /// `"LastHour"`, `"LastDay"`, `"LastWeek"`, `"LastMonth"`, and `"LastYear"`.
663 ///
664 /// # Parameters
665 ///
666 /// - `p.server_id` — Identifier of a configured DNS server (discoverable via `dns_list_servers`).
667 /// - `p.stats_type` — Optional time range for the statistics; defaults to `"LastDay"`.
668 ///
669 /// # Returns
670 ///
671 /// `CallToolResult` containing the requested statistics payload.
672 ///
673 /// # Examples
674 ///
675 /// ```ignore
676 /// # async fn example(dns_server: &crate::mcp::server::DnsServer) {
677 /// use crate::mcp::params::{Parameters, StatsParams};
678 ///
679 /// let params = Parameters(StatsParams {
680 /// server_id: "primary".to_string(),
681 /// stats_type: None,
682 /// });
683 ///
684 /// let result = dns_server.dns_get_stats(params).await;
685 /// assert!(result.is_ok());
686 /// # }
687 /// ```
688 #[tool(
689 description = "Get dashboard statistics. stats_type: LastHour, LastDay, LastWeek, LastMonth, LastYear. \
690 Use `server_id` from dns_list_servers."
691 )]
692 async fn dns_get_stats(
693 &self,
694 Parameters(p): Parameters<StatsParams>,
695 ) -> Result<CallToolResult, McpError> {
696 tracing::info!(tool = "dns_get_stats", "MCP tool invoked");
697 tracing::debug!(
698 stats_type = p.stats_type.as_deref().unwrap_or("LastDay"),
699 "tool invoked"
700 );
701 let (client, policy) = self.resolve_server(&p.server_id).map_err(mcp_err)?;
702 stats_tools::handle_get_stats(&client, &policy, p).await
703 }
704
705 // ── Blocked ───────────────────────────────────────────────────────────
706
707 /// List manually blocked domain names for a configured server.
708 ///
709 /// Resolves the target server by `server_id` and returns the blocked-domain list as a tool call result.
710 ///
711 /// # Examples
712 ///
713 /// ```ignore
714 /// # use crate::mcp::server::DnsServer;
715 /// # use crate::mcp::params::ServerScopeParams;
716 /// # use crate::mcp::tools::Parameters;
717 /// # async fn example(server: &DnsServer) -> Result<(), crate::core::error::McpError> {
718 /// let params = Parameters(ServerScopeParams { server_id: "primary".into() });
719 /// let res = server.dns_list_blocked_zones(params).await?;
720 /// println!("{}", res);
721 /// # Ok(())
722 /// # }
723 /// ```
724 #[tool(description = "List all manually blocked domains. \
725 Use `server_id` from dns_list_servers.")]
726 async fn dns_list_blocked_zones(
727 &self,
728 Parameters(p): Parameters<ServerScopeParams>,
729 ) -> Result<CallToolResult, McpError> {
730 tracing::info!(tool = "dns_list_blocked_zones", "MCP tool invoked");
731 let (client, policy) = self.resolve_server(&p.server_id).map_err(mcp_err)?;
732 access_lists::handle_list_blocked(&client, &policy).await
733 }
734
735 /// Adds a domain to the specified server's blocked list so that the DNS server will refuse to resolve it.
736 ///
737 /// The `DomainParams.server_id` selects which configured DNS server to affect (discoverable via `dns_list_servers`).
738 ///
739 /// # Returns
740 ///
741 /// `Ok(CallToolResult)` on success, `Err(McpError)` on failure.
742 ///
743 /// # Examples
744 ///
745 /// ```ignore
746 /// # use crate::mcp::params::DomainParams;
747 /// # async fn example(server: &crate::mcp::server::DnsServer) {
748 /// let params = DomainParams { server_id: "default".into(), domain: "example.com".into() };
749 /// let res = server.dns_add_blocked_zone(crate::mcp::Parameters(params)).await;
750 /// assert!(res.is_ok());
751 /// # }
752 /// ```
753 #[tool(
754 description = "Block a domain, causing the DNS server to refuse to resolve it. \
755 Use `server_id` from dns_list_servers."
756 )]
757 async fn dns_add_blocked_zone(
758 &self,
759 Parameters(p): Parameters<DomainParams>,
760 ) -> Result<CallToolResult, McpError> {
761 tracing::info!(tool = "dns_add_blocked_zone", "MCP tool invoked");
762 tracing::debug!(domain = %p.domain, "tool invoked");
763 let (client, policy) = self.resolve_server(&p.server_id).map_err(mcp_err)?;
764 access_lists::handle_add_blocked(&client, &policy, p).await
765 }
766
767 /// Remove a domain from a server's manual blocked (deny) list.
768 ///
769 /// The `server_id` identifies which configured DNS backend to operate on; use
770 /// `dns_list_servers` to discover available IDs.
771 ///
772 /// # Examples
773 ///
774 /// ```ignore
775 /// # use crate::mcp::server::DnsServer;
776 /// # use crate::mcp::params::DomainParams;
777 /// # use crate::mcp::tools::CallToolResult;
778 /// # use crate::mcp::error::McpError;
779 /// # use axum::extract::Parameters;
780 /// # #[tokio::main]
781 /// # async fn main() -> Result<(), McpError> {
782 /// let server = /* construct or obtain a DnsServer instance */;
783 /// let params = DomainParams { server_id: "primary".into(), domain: "example.com".into() };
784 /// let result: CallToolResult = server.dns_delete_blocked_zone(Parameters(params)).await?;
785 /// # Ok(()) }
786 /// ```
787 #[tool(description = "Unblock a domain. \
788 Use `server_id` from dns_list_servers.")]
789 async fn dns_delete_blocked_zone(
790 &self,
791 Parameters(p): Parameters<DomainParams>,
792 ) -> Result<CallToolResult, McpError> {
793 tracing::info!(tool = "dns_delete_blocked_zone", "MCP tool invoked");
794 tracing::debug!(domain = %p.domain, "tool invoked");
795 let (client, policy) = self.resolve_server(&p.server_id).map_err(mcp_err)?;
796 access_lists::handle_delete_blocked(&client, &policy, p).await
797 }
798
799 // ── Allowed ───────────────────────────────────────────────────────────
800
801 /// List whitelisted (allowed) domains for the specified DNS server.
802 ///
803 /// # Returns
804 ///
805 /// `Ok(CallToolResult)` containing the allowed domains list, `Err(McpError)` on failure.
806 ///
807 /// # Examples
808 ///
809 /// ```ignore
810 /// // In an async context:
811 /// let params = ServerScopeParams { server_id: "example-server".to_string() };
812 /// let result = server.dns_list_allowed_zones(Parameters(params)).await;
813 /// assert!(result.is_ok());
814 /// ```
815 #[tool(description = "List all whitelisted domains. \
816 Use `server_id` from dns_list_servers.")]
817 async fn dns_list_allowed_zones(
818 &self,
819 Parameters(p): Parameters<ServerScopeParams>,
820 ) -> Result<CallToolResult, McpError> {
821 tracing::info!(tool = "dns_list_allowed_zones", "MCP tool invoked");
822 let (client, policy) = self.resolve_server(&p.server_id).map_err(mcp_err)?;
823 access_lists::handle_list_allowed(&client, &policy).await
824 }
825
826 /// Adds a domain to a server's allow list so it will be permitted even if present on a block list.
827 ///
828 /// The `DomainParams.server_id` selects which configured DNS server to act on (discoverable via `dns_list_servers`), and `DomainParams.domain` is the domain to allow.
829 ///
830 /// # Examples
831 ///
832 /// ```ignore
833 /// // Prepare parameters and call the tool
834 /// let params = DomainParams { server_id: "prod".into(), domain: "example.com".into() };
835 /// let result = dns_server.dns_add_allowed_zone(Parameters(params)).await?;
836 /// ```
837 #[tool(
838 description = "Whitelist a domain, allowing it even if it appears on a block list. \
839 Use `server_id` from dns_list_servers."
840 )]
841 async fn dns_add_allowed_zone(
842 &self,
843 Parameters(p): Parameters<DomainParams>,
844 ) -> Result<CallToolResult, McpError> {
845 tracing::info!(tool = "dns_add_allowed_zone", "MCP tool invoked");
846 tracing::debug!(domain = %p.domain, "tool invoked");
847 let (client, policy) = self.resolve_server(&p.server_id).map_err(mcp_err)?;
848 access_lists::handle_add_allowed(&client, &policy, p).await
849 }
850
851 /// Removes a domain from a server's allow list (whitelist).
852 ///
853 /// # Examples
854 ///
855 /// ```ignore
856 /// # use crate::mcp::server::DnsServer;
857 /// # use crate::mcp::params::DomainParams;
858 /// # async fn example(srv: &DnsServer) {
859 /// let params = DomainParams { server_id: "server1".into(), domain: "example.com".into() };
860 /// // Call the tool and await the result
861 /// let _ = srv.dns_delete_allowed_zone(crate::mcp::server::Parameters(params)).await;
862 /// # }
863 /// ```
864 #[tool(description = "Remove a domain from the whitelist. \
865 Use `server_id` from dns_list_servers.")]
866 async fn dns_delete_allowed_zone(
867 &self,
868 Parameters(p): Parameters<DomainParams>,
869 ) -> Result<CallToolResult, McpError> {
870 tracing::info!(tool = "dns_delete_allowed_zone", "MCP tool invoked");
871 tracing::debug!(domain = %p.domain, "tool invoked");
872 let (client, policy) = self.resolve_server(&p.server_id).map_err(mcp_err)?;
873 access_lists::handle_delete_allowed(&client, &policy, p).await
874 }
875
876 // ── Settings ──────────────────────────────────────────────────────────
877
878 /// Retrieve the active DNS configuration for the specified server.
879 ///
880 /// The `server_id` in `ServerScopeParams` must identify one of the configured servers (see `dns_list_servers`).
881 ///
882 /// On success returns a `CallToolResult` containing the server settings; on failure returns an `McpError`.
883 ///
884 /// # Examples
885 ///
886 /// ```ignore
887 /// # async fn example_usage(server: &crate::mcp::server::DnsServer) {
888 /// use axum::extract::Parameters;
889 /// use crate::mcp::params::ServerScopeParams;
890 ///
891 /// let params = ServerScopeParams { server_id: "primary".to_string() };
892 /// let res = server.dns_get_settings(Parameters(params)).await;
893 /// // `res` is `Ok(CallToolResult)` on success
894 /// # }
895 /// ```
896 #[tool(description = "Get the current DNS server configuration. \
897 Use `server_id` from dns_list_servers.")]
898 async fn dns_get_settings(
899 &self,
900 Parameters(p): Parameters<ServerScopeParams>,
901 ) -> Result<CallToolResult, McpError> {
902 tracing::info!(tool = "dns_get_settings", "MCP tool invoked");
903 let (client, policy) = self.resolve_server(&p.server_id).map_err(mcp_err)?;
904 let show_secrets = self.show_settings_secrets(&p.server_id).map_err(mcp_err)?;
905 settings_tools::handle_get_settings(&client, &policy, show_secrets).await
906 }
907
908 // ── Logs ──────────────────────────────────────────────────────────────
909
910 /// Retrieve DNS server logs from the specified configured backend.
911 #[tool(
912 description = "Get DNS server logs. Use `server_id` from dns_list_servers. \
913 Optional filters: lines, start, end, and level."
914 )]
915 async fn dns_logs(
916 &self,
917 Parameters(p): Parameters<LogsParams>,
918 ) -> Result<CallToolResult, McpError> {
919 tracing::info!(tool = "dns_logs", "MCP tool invoked");
920 let (client, policy) = self.resolve_server(&p.server_id).map_err(mcp_err)?;
921 logs_tools::handle_logs(&client, &policy, p).await
922 }
923
924 // ── Sync ──────────────────────────────────────────────────────────────
925
926 #[tool(description = "Sync records between two configured servers. \
927 Dry-run by default; set `apply` to true to write changes.")]
928 async fn dns_sync(
929 &self,
930 Parameters(p): Parameters<SyncParams>,
931 ) -> Result<CallToolResult, McpError> {
932 tracing::info!(tool = "dns_sync", "MCP tool invoked");
933 let profile = p.profile.as_deref().and_then(|name| {
934 self.config
935 .sync
936 .iter()
937 .find(|profile| profile.name.eq_ignore_ascii_case(name))
938 });
939 let from_id = p
940 .from
941 .as_deref()
942 .or_else(|| profile.map(|profile| profile.from.as_str()))
943 .ok_or_else(|| {
944 mcp_err(crate::core::error::Error::parse(
945 "sync requires a source server: name a profile or pass from",
946 ))
947 })?;
948 let to_id =
949 p.to.as_deref()
950 .or_else(|| profile.map(|profile| profile.to.as_str()))
951 .ok_or_else(|| {
952 mcp_err(crate::core::error::Error::parse(
953 "sync requires a destination server: name a profile or pass to",
954 ))
955 })?;
956 let (_, from_policy) = self.resolve_server(from_id).map_err(mcp_err)?;
957 let (_, to_policy) = self.resolve_server(to_id).map_err(mcp_err)?;
958 sync_tools::handle_sync(&self.config, &from_policy, &to_policy, p).await
959 }
960
961 // ── Direct DNS resolution (mirrors `dns query`) ───────────────────────
962
963 /// Resolve a name directly via the system resolver, a configured
964 /// `[[servers]]` entry, or any ad-hoc nameserver. Supports DNS,
965 /// DoT, DoH, and (with the `doq` Cargo feature) DoQ.
966 ///
967 /// Mirrors the `dns query` CLI subcommand and returns the same
968 /// stable JSON shape (`query`, `target`, `results` array — one
969 /// entry per transport). When `all_transports = true` or
970 /// `transports` lists multiple entries, the tool fans out across
971 /// every requested block in precedence order doh → dot → dns →
972 /// doq; the response's `results` length reflects the actual
973 /// transports queried.
974 #[tool(
975 description = "Resolve a name directly against the system resolver, a configured \
976 `[[servers]]` entry (via `server_id`), or any ad-hoc nameserver (via `at`). Supports DNS, \
977 DoT, DoH, and (with the `doq` build feature) DoQ. When `server_id` is given, transport \
978 selection follows `transports` / `all_transports`; otherwise the scheme on `at` chooses, \
979 or the system resolver is used. Returns the same JSON shape as `dns query --json` — a \
980 `results` array with one entry per transport queried."
981 )]
982 async fn dns_resolve(
983 &self,
984 Parameters(p): Parameters<ResolveParams>,
985 ) -> Result<CallToolResult, McpError> {
986 tracing::info!(tool = "dns_resolve", "MCP tool invoked");
987 resolve_tools::handle_resolve(&self.config, &self.cli_access, &self.cli_allow_zone, p).await
988 }
989}
990
991// ─── ServerHandler ────────────────────────────────────────────────────────────
992
993#[tool_handler]
994impl ServerHandler for DnsServer {
995 /// Builds the ServerInfo metadata describing this DNS MCP server.
996 ///
997 /// The returned `ServerInfo` contains the protocol version, enabled capabilities,
998 /// human-facing instructions (including the server's startup info), and implementation
999 /// metadata with the implementation name set to `"dns"`.
1000 ///
1001 /// # Examples
1002 ///
1003 /// ```ignore
1004 /// use std::sync::Arc;
1005 ///
1006 /// // Construct a server (example uses default/empty inputs for brevity).
1007 /// let config = AppConfig::default();
1008 /// let server = DnsServer::new(config, Vec::new(), Vec::new());
1009 /// let info = server.get_info();
1010 /// assert_eq!(info.server_info.name, "dns");
1011 /// ```
1012 fn get_info(&self) -> ServerInfo {
1013 let base = "MCP server for DNS management. Manages zones, records, cache, stats, \
1014 and block/allow lists. Confirm before calling any destructive tool.";
1015
1016 let mut info = ServerInfo::default();
1017 info.protocol_version = ProtocolVersion::V_2024_11_05;
1018 info.capabilities = ServerCapabilities::builder().enable_tools().build();
1019 info.instructions = Some(format!("{base}{}", self.startup_info));
1020
1021 let mut impl_info = Implementation::from_build_env();
1022 impl_info.name = "dns".into();
1023 info.server_info = impl_info;
1024
1025 info
1026 }
1027}