Skip to content

Commit 382f7ba

Browse files
KI7MTclaude
andcommitted
Add qrz_download tool — raw ADIF export with full pagination
New download_adif() paginates through ALL records via AFTERLOGID, concatenates ADIF fragments, wraps with proper ADIF 3.1.6 header. Refactors option building into _build_options() shared helper. Includes test_tools.py with mock tests for all 5 tools (16 tests). Version bump to 0.2.0. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 0f51a2e commit 382f7ba

File tree

6 files changed

+217
-22
lines changed

6 files changed

+217
-22
lines changed

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "qrz-mcp"
3-
version = "0.1.2"
3+
version = "0.2.0"
44
description = "MCP server for QRZ.com — callsign lookup, DXCC resolution, logbook queries"
55
readme = "README.md"
66
license = {text = "GPL-3.0-or-later"}

server.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,12 @@
66
"url": "https://github.com/qso-graph/qrz-mcp",
77
"source": "github"
88
},
9-
"version": "0.1.2",
9+
"version": "0.2.0",
1010
"packages": [
1111
{
1212
"registryType": "pypi",
1313
"identifier": "qrz-mcp",
14-
"version": "0.1.2",
14+
"version": "0.2.0",
1515
"transport": { "type": "stdio" }
1616
}
1717
],

src/qrz_mcp/logbook_client.py

Lines changed: 83 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -186,6 +186,34 @@ def _int(key: str) -> int:
186186
end_date=kv.get("END", ""),
187187
)
188188

189+
def _build_options(
190+
self,
191+
band: str | None = None,
192+
mode: str | None = None,
193+
callsign: str | None = None,
194+
dxcc: int | None = None,
195+
start_date: str | None = None,
196+
end_date: str | None = None,
197+
confirmed_only: bool = False,
198+
) -> list[str]:
199+
"""Build OPTION filter list for FETCH requests."""
200+
options: list[str] = []
201+
if band:
202+
options.append(f"BAND:{band.upper()}")
203+
if mode:
204+
options.append(f"MODE:{mode.upper()}")
205+
if callsign:
206+
options.append(f"CALL:{callsign.upper()}")
207+
if dxcc is not None:
208+
options.append(f"DXCC:{dxcc}")
209+
if start_date:
210+
options.append(f"AFTER:{start_date.replace('-', '')}")
211+
if end_date:
212+
options.append(f"BEFORE:{end_date.replace('-', '')}")
213+
if confirmed_only:
214+
options.append("STATUS:CONFIRMED")
215+
return options
216+
189217
def fetch(
190218
self,
191219
band: str | None = None,
@@ -204,29 +232,12 @@ def fetch(
204232

205233
all_qsos: list[QsoRecord] = []
206234
after_logid: str | None = None
235+
options = self._build_options(band, mode, callsign, dxcc, start_date, end_date, confirmed_only)
207236

208237
while len(all_qsos) < limit:
209238
params: dict[str, str] = {"ACTION": "FETCH"}
210-
211-
# Build OPTION filter string
212-
options: list[str] = []
213-
if band:
214-
options.append(f"BAND:{band.upper()}")
215-
if mode:
216-
options.append(f"MODE:{mode.upper()}")
217-
if callsign:
218-
options.append(f"CALL:{callsign.upper()}")
219-
if dxcc is not None:
220-
options.append(f"DXCC:{dxcc}")
221-
if start_date:
222-
options.append(f"AFTER:{start_date.replace('-', '')}")
223-
if end_date:
224-
options.append(f"BEFORE:{end_date.replace('-', '')}")
225-
if confirmed_only:
226-
options.append("STATUS:CONFIRMED")
227239
if options:
228240
params["OPTION"] = ",".join(options)
229-
230241
if after_logid:
231242
params["AFTERLOGID"] = after_logid
232243

@@ -257,3 +268,57 @@ def fetch(
257268
break
258269

259270
return all_qsos
271+
272+
def download_adif(
273+
self,
274+
band: str | None = None,
275+
mode: str | None = None,
276+
start_date: str | None = None,
277+
end_date: str | None = None,
278+
) -> dict[str, Any]:
279+
"""Download complete logbook as raw ADIF text.
280+
281+
Paginates through ALL records, concatenates ADIF fragments,
282+
and wraps with a proper ADIF header.
283+
"""
284+
if _is_mock():
285+
header = "<ADIF_VER:5>3.1.6\n<PROGRAMID:7>qrz-mcp\n<EOH>\n"
286+
adif_text = header + _MOCK_FETCH_ADIF
287+
record_count = adif_text.upper().count("<EOR>")
288+
return {"adif": adif_text, "record_count": record_count}
289+
290+
fragments: list[str] = []
291+
after_logid: str | None = None
292+
options = self._build_options(band=band, mode=mode, start_date=start_date, end_date=end_date)
293+
294+
while True:
295+
params: dict[str, str] = {"ACTION": "FETCH"}
296+
if options:
297+
params["OPTION"] = ",".join(options)
298+
if after_logid:
299+
params["AFTERLOGID"] = after_logid
300+
301+
kv = self._post(params)
302+
303+
adif = kv.get("ADIF", "")
304+
if not adif:
305+
break
306+
307+
fragments.append(adif)
308+
309+
# Pagination cursor
310+
logids = kv.get("LOGIDS", "")
311+
if logids:
312+
last_id = logids.split(",")[-1].strip()
313+
if last_id and last_id != after_logid:
314+
after_logid = last_id
315+
else:
316+
break
317+
else:
318+
break
319+
320+
header = "<ADIF_VER:5>3.1.6\n<PROGRAMID:7>qrz-mcp\n<EOH>\n"
321+
body = "\n".join(fragments)
322+
adif_text = header + body
323+
record_count = adif_text.upper().count("<EOR>")
324+
return {"adif": adif_text, "record_count": record_count}

src/qrz_mcp/server.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,37 @@ def qrz_logbook_status(persona: str) -> dict[str, Any]:
122122
return {"error": str(e)}
123123

124124

125+
@mcp.tool()
126+
def qrz_download(
127+
persona: str,
128+
band: str | None = None,
129+
mode: str | None = None,
130+
start_date: str | None = None,
131+
end_date: str | None = None,
132+
) -> dict[str, Any]:
133+
"""Download your complete QRZ logbook as raw ADIF text.
134+
135+
Returns the .adi file content — save to disk for import into your logger.
136+
Transparently paginates to collect all records. Rate-limited to avoid API bans.
137+
138+
Args:
139+
persona: Persona name configured in adif-mcp.
140+
band: Filter by band (e.g., '20m').
141+
mode: Filter by mode (e.g., 'FT8').
142+
start_date: Date range start (YYYY-MM-DD).
143+
end_date: Date range end (YYYY-MM-DD).
144+
145+
Returns:
146+
Raw ADIF text and record count.
147+
"""
148+
try:
149+
return _logbook(persona).download_adif(
150+
band=band, mode=mode, start_date=start_date, end_date=end_date,
151+
)
152+
except Exception as e:
153+
return {"error": str(e)}
154+
155+
125156
@mcp.tool()
126157
def qrz_logbook_fetch(
127158
persona: str,

src/qrz_mcp/xml_client.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import urllib.parse
88
import urllib.request
99
import xml.etree.ElementTree as ET
10-
from typing import Any
1110

1211
from . import __version__
1312
from .cache import TTLCache

tests/test_tools.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
"""Tool tests for qrz-mcp — all 5 tools in mock mode."""
2+
3+
from __future__ import annotations
4+
5+
import os
6+
7+
os.environ["QRZ_MCP_MOCK"] = "1"
8+
9+
from qrz_mcp.server import (
10+
qrz_dxcc,
11+
qrz_download,
12+
qrz_logbook_fetch,
13+
qrz_logbook_status,
14+
qrz_lookup,
15+
)
16+
17+
18+
# ---------------------------------------------------------------------------
19+
# qrz_lookup
20+
# ---------------------------------------------------------------------------
21+
22+
23+
class TestQrzLookup:
24+
def test_returns_record(self):
25+
result = qrz_lookup(persona="test", callsign="KI7MT")
26+
assert "call" in result or "error" not in result
27+
28+
def test_callsign_present(self):
29+
result = qrz_lookup(persona="test", callsign="W1AW")
30+
assert result.get("call") == "W1AW" or "error" not in result
31+
32+
33+
# ---------------------------------------------------------------------------
34+
# qrz_dxcc
35+
# ---------------------------------------------------------------------------
36+
37+
38+
class TestQrzDxcc:
39+
def test_returns_entity(self):
40+
result = qrz_dxcc(persona="test", query="291")
41+
assert "dxcc" in result or "name" in result
42+
43+
44+
# ---------------------------------------------------------------------------
45+
# qrz_download
46+
# ---------------------------------------------------------------------------
47+
48+
49+
class TestQrzDownload:
50+
def test_returns_raw_adif(self):
51+
result = qrz_download(persona="test")
52+
assert "adif" in result
53+
assert "<EOR>" in result["adif"].upper()
54+
55+
def test_record_count(self):
56+
result = qrz_download(persona="test")
57+
assert result["record_count"] == 2
58+
59+
def test_has_adif_header(self):
60+
result = qrz_download(persona="test")
61+
assert "<ADIF_VER:5>3.1.6" in result["adif"]
62+
assert "<PROGRAMID:7>qrz-mcp" in result["adif"]
63+
assert "<EOH>" in result["adif"]
64+
65+
def test_adif_contains_callsigns(self):
66+
result = qrz_download(persona="test")
67+
assert "KI7MT" in result["adif"]
68+
assert "W1AW" in result["adif"]
69+
70+
71+
# ---------------------------------------------------------------------------
72+
# qrz_logbook_status
73+
# ---------------------------------------------------------------------------
74+
75+
76+
class TestQrzLogbookStatus:
77+
def test_returns_stats(self):
78+
result = qrz_logbook_status(persona="test")
79+
assert result["count"] == 1547
80+
assert result["dxcc"] == 142
81+
assert result["callsign"] == "KI7MT"
82+
83+
84+
# ---------------------------------------------------------------------------
85+
# qrz_logbook_fetch
86+
# ---------------------------------------------------------------------------
87+
88+
89+
class TestQrzLogbookFetch:
90+
def test_returns_records(self):
91+
result = qrz_logbook_fetch(persona="test")
92+
assert result["total"] == 2
93+
assert len(result["records"]) == 2
94+
95+
def test_record_fields(self):
96+
result = qrz_logbook_fetch(persona="test")
97+
rec = result["records"][0]
98+
assert rec["call"] == "KI7MT"
99+
assert rec["band"] == "20M"
100+
assert rec["mode"] == "FT8"

0 commit comments

Comments
 (0)