Skip to content

Commit 6b6eddf

Browse files
authored
fix: optional-mcp import, flat-FQDN listing, and BloxOne idempotency (#175)
* fix(sdk): import mcp telemetry types under TYPE_CHECKING The MCP telemetry seam imported McpHttpClientFactory from the optional mcp package at module load for a single return-type annotation, so importing dns_aid (and therefore the CLI) failed with ModuleNotFoundError when the mcp extra was not installed. Move the import under TYPE_CHECKING; from __future__ import annotations already keeps the annotation lazy, so the package and CLI import with the core dependencies alone. Signed-off-by: Igor Racic <iracic82@gmail.com> * fix(backends): make Infoblox BloxOne writes idempotent create_svcb_record and create_txt_record POST-created records unconditionally, so repeated index updates and re-publishes accumulated duplicate records and eventually returned 409 Conflict on the _index._agents TXT. Replace any existing record at the same (name, type) before writing, an upsert matching the Route 53 backend's UPSERT contract that also self-heals pre-existing duplicates. Signed-off-by: Igor Racic <iracic82@gmail.com> * fix(core): list flat-FQDN agents in list and list_published_agents Listing filtered records by the _agents substring, which under draft-02 matched only the organization index and walkable aliases and missed every flat agent owner ({name}.{domain}). Add dns_aid.core.lister to identify DNS-AID records by structure (SVCB owners + companion TXT + _agents bookkeeping) and use it from the CLI list command and the list_published_agents MCP tool. Signed-off-by: Igor Racic <iracic82@gmail.com> * docs: changelog for cli import, flat listing, and bloxone idempotency Signed-off-by: Igor Racic <iracic82@gmail.com> --------- Signed-off-by: Igor Racic <iracic82@gmail.com>
1 parent f6486a5 commit 6b6eddf

9 files changed

Lines changed: 381 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Fixed
11+
12+
- **`dns-aid[cli]` imports without the `mcp` extra.** The MCP telemetry seam
13+
imported `mcp.shared._httpx_utils.McpHttpClientFactory` at module load for a
14+
single return-type annotation, so `import dns_aid` (and therefore the CLI)
15+
failed with `ModuleNotFoundError: No module named 'mcp'` whenever the `mcp`
16+
extra was not installed. The import now lives under `TYPE_CHECKING`
17+
(`from __future__ import annotations` already keeps the annotation lazy), so
18+
the package and CLI import with the core dependencies alone.
19+
- **`list` and `list_published_agents` surface flat-FQDN agents.** Listing
20+
filtered records by the `_agents` substring, which under draft-02 matched
21+
only the organization index and walkable aliases and silently missed every
22+
flat agent owner (`{name}.{domain}`). A new `dns_aid.core.lister` identifies
23+
DNS-AID records by structure (SVCB owners + companion TXT + `_agents`
24+
bookkeeping); the CLI `list` command and the `list_published_agents` MCP tool
25+
now report the same, complete set.
26+
- **Idempotent Infoblox BloxOne writes.** `create_svcb_record` and
27+
`create_txt_record` POST-created records unconditionally, so repeated index
28+
updates and re-publishes accumulated duplicate records and eventually failed
29+
with `409 Conflict` on the `_index._agents` TXT. They now replace any
30+
existing record at the same `(name, type)` before writing — an upsert that
31+
matches the Route 53 backend's `UPSERT` contract and self-heals pre-existing
32+
duplicates.
33+
1034
## [0.24.3] - 2026-06-04
1135

1236
### Fixed

src/dns_aid/backends/infoblox/bloxone.py

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,59 @@ def _format_svcb_rdata(
291291
"target_name": target,
292292
}
293293

294+
async def _delete_existing_records(
295+
self, zone_id: str, name_in_zone: str, record_type: str
296+
) -> int:
297+
"""Delete every record at ``(name_in_zone, record_type)`` in the zone.
298+
299+
DNS-AID owns the names it writes (agent owners and ``_index._agents``)
300+
and publishes exactly one record per ``(name, type)``. Removing any
301+
existing record(s) before a create makes the write idempotent (an
302+
upsert) and self-heals accidental duplicates left by earlier
303+
non-idempotent writes — matching the UPSERT contract other backends
304+
(e.g. Route53) already provide, and avoiding BloxOne ``409 Conflict``
305+
on repeated index updates and re-publishes.
306+
307+
Returns the number of records deleted.
308+
"""
309+
deleted = 0
310+
# Bounded loop: re-query from the top after each batch (deletions shift
311+
# pagination). The cap is a safety stop, far above any real fan-out.
312+
for _ in range(50):
313+
response = await self._request(
314+
"GET",
315+
"/dns/record",
316+
params={
317+
"_filter": (
318+
f'zone=="{zone_id}" and name_in_zone=="{name_in_zone}" '
319+
f'and type=="{record_type}"'
320+
),
321+
"_limit": "100",
322+
},
323+
)
324+
results = response.get("results", [])
325+
if not results:
326+
break
327+
for record in results:
328+
record_id = record.get("id")
329+
if record_id:
330+
await self._request("DELETE", f"/{record_id}")
331+
deleted += 1
332+
# A partial page means we have just deleted the last of them, so
333+
# there is nothing left to re-query. Only loop again if the page
334+
# was full (which a real DNS-AID name never reaches).
335+
if len(results) < 100:
336+
break
337+
if deleted:
338+
logger.debug(
339+
"Replaced existing record(s) before write",
340+
zone_id=zone_id,
341+
name=name_in_zone,
342+
type=record_type,
343+
deleted=deleted,
344+
)
345+
return deleted
346+
294347
async def create_svcb_record(
295348
self,
296349
zone: str,
@@ -308,6 +361,11 @@ async def create_svcb_record(
308361
# name comes as "_agent._mcp._agents"
309362
name_in_zone = name
310363

364+
# Idempotent write (upsert): replace any existing SVCB at this name so
365+
# re-publishing an agent updates in place instead of accumulating
366+
# duplicate records (see _delete_existing_records).
367+
await self._delete_existing_records(zone_id, name_in_zone, "SVCB")
368+
311369
# Build FQDN for logging
312370
fqdn = f"{name}.{zone}"
313371

@@ -355,6 +413,11 @@ async def create_txt_record(
355413
zone_info = await self._get_zone_info(zone)
356414
zone_id = zone_info["id"]
357415

416+
# Idempotent write (upsert): replace any existing TXT at this name.
417+
# Without this, repeated index updates (_index._agents) POST-create
418+
# duplicate TXT records and eventually 409 on BloxOne.
419+
await self._delete_existing_records(zone_id, name, "TXT")
420+
358421
# Build FQDN
359422
fqdn = f"{name}.{zone}"
360423

src/dns_aid/cli/main.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -787,22 +787,22 @@ def list_records(
787787
"""
788788
List DNS-AID records in a domain.
789789
790-
Shows all _agents.* records in the specified zone.
790+
Shows the flat agent owners ({name}.{domain} SVCB + TXT), the organization
791+
index (_index._agents), and any walkable aliases in the specified zone.
791792
792793
Example:
793794
dns-aid list example.com
794795
"""
796+
from dns_aid.core.lister import list_dns_aid_records
797+
795798
dns_backend = _get_backend(backend)
796799

797800
console.print(f"\n[bold]DNS-AID records in {domain}:[/bold]\n")
798801

799802
async def list_all():
800803
if not await dns_backend.zone_exists(domain):
801804
return None # sentinel: zone not found
802-
records = []
803-
async for record in dns_backend.list_records(domain, name_pattern="_agents"):
804-
records.append(record)
805-
return records
805+
return await list_dns_aid_records(dns_backend, domain)
806806

807807
try:
808808
records = run_async(list_all())

src/dns_aid/core/lister.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
# Copyright 2024-2026 The DNS-AID Authors
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Enumerate the DNS-AID records published in a zone, via a DNS backend.
5+
6+
Under draft-mozleywilliams-dnsop-dnsaid-02 an agent's primary owner is the
7+
**flat** FQDN ``{name}.{domain}`` (SVCB + companion TXT). The pre-flat
8+
listing logic filtered records by the substring ``_agents`` in the owner
9+
label, which only ever matched the organization index (``_index._agents``)
10+
and walkable aliases (``{name}._agents``) — it silently missed every flat
11+
agent owner. This module identifies DNS-AID records by *structure* instead of
12+
by a name substring, so flat owners are surfaced.
13+
14+
A record is a DNS-AID record when it is:
15+
16+
* an **SVCB** record — every published agent (flat owner) and every walkable
17+
alias has exactly one;
18+
* a **TXT** record sharing an owner with an SVCB record — the companion
19+
capability/metadata TXT of a flat agent owner;
20+
* a record in the ``_agents`` bookkeeping namespace — the organization index
21+
(``_index._agents``) and walkable-alias owners.
22+
23+
System records (SOA/NS/A/AAAA/CNAME/...) are excluded. Used by ``dns-aid
24+
list`` (CLI) and the ``list_published_agents`` MCP tool so both interfaces
25+
report the same, correct set.
26+
"""
27+
28+
from __future__ import annotations
29+
30+
from typing import TYPE_CHECKING, Any
31+
32+
if TYPE_CHECKING:
33+
from dns_aid.backends.base import DNSBackend
34+
35+
# The label that marks the DNS-AID bookkeeping namespace (index + walkable
36+
# aliases). Agent *primary* owners are flat and do NOT contain it — that is
37+
# exactly why a substring filter on it is insufficient on its own.
38+
_AGENTS_LABEL = "_agents"
39+
40+
41+
def _owner(record: dict[str, Any]) -> str:
42+
"""Stable owner key for a record: name-in-zone, falling back to the FQDN."""
43+
return record.get("name") or record.get("fqdn", "")
44+
45+
46+
async def list_dns_aid_records(backend: DNSBackend, domain: str) -> list[dict[str, Any]]:
47+
"""Return the DNS-AID records published under ``domain``.
48+
49+
Issues two type-filtered backend queries (SVCB, TXT) so unrelated system
50+
records are never fetched. The result preserves the backend's record-dict
51+
shape (``name``, ``fqdn``, ``type``, ``ttl``, ``values``, ``id``) and
52+
de-duplicates by ``(fqdn, type)``.
53+
54+
Args:
55+
backend: DNS backend to query.
56+
domain: Zone to enumerate.
57+
58+
Returns:
59+
DNS-AID records (SVCB owners + walkable aliases + companion TXT +
60+
the ``_agents`` bookkeeping records), in backend order.
61+
"""
62+
svcb_records = [r async for r in backend.list_records(domain, record_type="SVCB")]
63+
txt_records = [r async for r in backend.list_records(domain, record_type="TXT")]
64+
65+
svcb_owners = {_owner(r) for r in svcb_records}
66+
67+
out: list[dict[str, Any]] = list(svcb_records)
68+
for record in txt_records:
69+
owner = _owner(record)
70+
# Companion TXT of a flat agent owner, or an `_agents` bookkeeping TXT.
71+
if owner in svcb_owners or _AGENTS_LABEL in owner:
72+
out.append(record)
73+
74+
# De-duplicate defensively (a backend could surface a record under both
75+
# queries); keep first occurrence and preserve order.
76+
seen: set[tuple[str, str]] = set()
77+
deduped: list[dict[str, Any]] = []
78+
for record in out:
79+
key = (record.get("fqdn", ""), record.get("type", ""))
80+
if key in seen:
81+
continue
82+
seen.add(key)
83+
deduped.append(record)
84+
return deduped

src/dns_aid/mcp/server.py

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1009,12 +1009,11 @@ def list_published_agents(
10091009
dns_backend = _get_dns_backend(backend)
10101010

10111011
async def _list():
1012+
from dns_aid.core.lister import list_dns_aid_records
1013+
10121014
if not await dns_backend.zone_exists(domain):
10131015
return None # sentinel: zone not found
1014-
records = []
1015-
async for record in dns_backend.list_records(domain, name_pattern="_agents"):
1016-
records.append(record)
1017-
return records
1016+
return await list_dns_aid_records(dns_backend, domain)
10181017

10191018
try:
10201019
records = _run_async(_list())

src/dns_aid/sdk/protocols/_mcp_telemetry.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,19 @@
1919

2020
import time
2121
from dataclasses import dataclass, field
22+
from typing import TYPE_CHECKING
2223

2324
import httpx
24-
from mcp.shared._httpx_utils import McpHttpClientFactory
2525

2626
from dns_aid.sdk.telemetry.propagation import inject_otel_context
2727

28+
if TYPE_CHECKING:
29+
# `mcp` is an optional dependency (the `mcp` extra). It is referenced here
30+
# only as a return-type annotation, which `from __future__ import
31+
# annotations` keeps lazy — so importing this module (and therefore the
32+
# whole `dns_aid` package and CLI) must not require `mcp` to be installed.
33+
from mcp.shared._httpx_utils import McpHttpClientFactory
34+
2835

2936
@dataclass
3037
class _TelemetryCapture:
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Copyright 2024-2026 The DNS-AID Authors
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""Regression: the package (and CLI) must import without the optional ``mcp`` extra.
5+
6+
``mcp`` is declared in the ``mcp`` extra, not the core dependencies. A stray
7+
module-level ``from mcp...`` in the SDK import chain made ``import dns_aid``
8+
(and therefore ``dns-aid[cli]`` with no ``mcp`` extra) fail with
9+
``ModuleNotFoundError: No module named 'mcp'``. The MCP telemetry seam only
10+
references ``mcp`` as a type annotation, so the import belongs under
11+
``TYPE_CHECKING``. These tests pin that contract.
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import builtins
17+
import importlib
18+
import sys
19+
20+
21+
def _block_mcp(monkeypatch) -> None:
22+
"""Make any ``import mcp`` / ``from mcp...`` raise, simulating the absent extra."""
23+
for mod in list(sys.modules):
24+
if mod == "mcp" or mod.startswith("mcp."):
25+
monkeypatch.delitem(sys.modules, mod, raising=False)
26+
27+
real_import = builtins.__import__
28+
29+
def fake_import(name, *args, **kwargs):
30+
if name == "mcp" or name.startswith("mcp."):
31+
raise ModuleNotFoundError("No module named 'mcp' (simulated: extra not installed)")
32+
return real_import(name, *args, **kwargs)
33+
34+
monkeypatch.setattr(builtins, "__import__", fake_import)
35+
36+
37+
def test_mcp_telemetry_module_imports_without_mcp(monkeypatch):
38+
"""The telemetry module references ``mcp`` only for typing — import must not need it."""
39+
_block_mcp(monkeypatch)
40+
monkeypatch.delitem(sys.modules, "dns_aid.sdk.protocols._mcp_telemetry", raising=False)
41+
42+
mod = importlib.import_module("dns_aid.sdk.protocols._mcp_telemetry")
43+
44+
assert hasattr(mod, "_make_telemetry_factory")
45+
# The factory builds a plain httpx client factory and never touches ``mcp``.
46+
assert "mcp" not in sys.modules
47+
48+
49+
async def test_factory_builds_without_mcp(monkeypatch):
50+
"""The telemetry factory is callable and returns an httpx client without ``mcp``."""
51+
import httpx
52+
53+
_block_mcp(monkeypatch)
54+
monkeypatch.delitem(sys.modules, "dns_aid.sdk.protocols._mcp_telemetry", raising=False)
55+
mod = importlib.import_module("dns_aid.sdk.protocols._mcp_telemetry")
56+
57+
capture = mod._TelemetryCapture()
58+
factory = mod._make_telemetry_factory(capture)
59+
client = factory()
60+
try:
61+
assert isinstance(client, httpx.AsyncClient)
62+
finally:
63+
await client.aclose()

0 commit comments

Comments
 (0)