Skip to content

Commit 19e245e

Browse files
committed
Initial commit: qrz-mcp v0.1.0
4 MCP tools: qrz_lookup, qrz_dxcc, qrz_logbook_status, qrz_logbook_fetch Dual auth: XML session keys (callsign/DXCC) + Logbook API keys (QSO queries) Rate limiting: 500ms delay, 35 req/min token bucket, IP ban detection In-memory TTL cache (5 min callsign, 1 hr DXCC) Mock mode: QRZ_MCP_MOCK=1 for testing without credentials
0 parents  commit 19e245e

File tree

12 files changed

+1238
-0
lines changed

12 files changed

+1238
-0
lines changed

.github/workflows/publish.yml

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
name: Publish to PyPI
2+
3+
on:
4+
push:
5+
tags:
6+
- "v*"
7+
8+
jobs:
9+
publish:
10+
name: Build and publish to PyPI
11+
runs-on: ubuntu-latest
12+
environment: pypi
13+
permissions:
14+
id-token: write # Required for trusted publishing (OIDC)
15+
steps:
16+
- uses: actions/checkout@v4
17+
18+
- uses: actions/setup-python@v5
19+
with:
20+
python-version: "3.12"
21+
22+
- name: Install build dependencies
23+
run: pip install build
24+
25+
- name: Build package
26+
run: python -m build
27+
28+
- name: Publish to PyPI
29+
uses: pypa/gh-action-pypi-publish@release/v1

.gitignore

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
__pycache__/
2+
*.pyc
3+
*.egg-info/
4+
dist/
5+
build/
6+
.venv/

LICENSE

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
GNU GENERAL PUBLIC LICENSE
2+
Version 3, 29 June 2007
3+
4+
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
5+
Everyone is permitted to copy and distribute verbatim copies
6+
of this license document, but changing it is not allowed.
7+
8+
Preamble
9+
10+
The GNU General Public License is a free, copyleft license for
11+
software and other kinds of works.
12+
13+
The licenses for most software and other practical works are designed
14+
to take away your freedom to share and change the works. By contrast,
15+
the GNU General Public License is intended to guarantee your freedom to
16+
share and change all versions of a program--to make sure it remains free
17+
software for all its users. We, the Free Software Foundation, use the
18+
GNU General Public License for most of our software; it applies also to
19+
any other work released this way by its authors. You can apply it to
20+
your programs, too.
21+
22+
For the full license text, see <https://www.gnu.org/licenses/gpl-3.0.html>

README.md

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
# qrz-mcp
2+
3+
MCP server for [QRZ.com](https://www.qrz.com/) — callsign lookups, DXCC entity resolution, and logbook queries through any MCP-compatible AI assistant.
4+
5+
Part of the [qso-graph](https://qso-graph.io/) project. Depends on [adif-mcp](https://pypi.org/project/adif-mcp/) for persona and credential management.
6+
7+
## Install
8+
9+
```bash
10+
pip install qrz-mcp
11+
```
12+
13+
## Tools
14+
15+
| Tool | API | Auth | Description |
16+
|------|-----|------|-------------|
17+
| `qrz_lookup` | XML | Session key | Callsign lookup (name, grid, DXCC, license class, QSL info, image) |
18+
| `qrz_dxcc` | XML | Session key | DXCC entity resolution from callsign or entity code |
19+
| `qrz_logbook_status` | Logbook | API key | Logbook stats (QSO count, DXCC total, date range) |
20+
| `qrz_logbook_fetch` | Logbook | API key | Query QSOs with filters and transparent pagination |
21+
22+
## Quick Start
23+
24+
### 1. Set up credentials
25+
26+
qrz-mcp uses adif-mcp personas for credential management. QRZ has **two separate auth mechanisms** — set up whichever you need:
27+
28+
```bash
29+
# Install adif-mcp if you haven't
30+
pip install adif-mcp
31+
32+
# Create a persona
33+
adif-mcp persona create ki7mt --callsign KI7MT
34+
35+
# Enable QRZ provider
36+
adif-mcp persona provider ki7mt qrz --username KI7MT
37+
38+
# Set password (for XML API: qrz_lookup, qrz_dxcc)
39+
adif-mcp persona secret ki7mt qrz
40+
41+
# Set API key (for Logbook API: qrz_logbook_status, qrz_logbook_fetch)
42+
adif-mcp creds set --persona ki7mt --provider qrz --api-key YOUR_API_KEY
43+
```
44+
45+
**XML API** (callsign lookup, DXCC) requires a QRZ XML Subscription ($35.95/yr). Free tier returns name and address only.
46+
47+
**Logbook API** requires an API key from QRZ Settings > API.
48+
49+
### 2. Configure your MCP client
50+
51+
qrz-mcp works with any MCP-compatible client. Add the server config and restart — tools appear automatically.
52+
53+
#### Claude Desktop
54+
55+
Add to `claude_desktop_config.json` (`~/Library/Application Support/Claude/` on macOS, `%APPDATA%\Claude\` on Windows):
56+
57+
```json
58+
{
59+
"mcpServers": {
60+
"qrz": {
61+
"command": "qrz-mcp"
62+
}
63+
}
64+
}
65+
```
66+
67+
#### Claude Code
68+
69+
Add to `.claude/settings.json`:
70+
71+
```json
72+
{
73+
"mcpServers": {
74+
"qrz": {
75+
"command": "qrz-mcp"
76+
}
77+
}
78+
}
79+
```
80+
81+
#### ChatGPT Desktop
82+
83+
ChatGPT supports MCP via the [OpenAI Agents SDK](https://developers.openai.com/api/docs/mcp/). Add under Settings > Apps & Connectors, or configure in your agent definition:
84+
85+
```json
86+
{
87+
"mcpServers": {
88+
"qrz": {
89+
"command": "qrz-mcp"
90+
}
91+
}
92+
}
93+
```
94+
95+
#### Cursor
96+
97+
Add to `.cursor/mcp.json` (project-level) or `~/.cursor/mcp.json` (global):
98+
99+
```json
100+
{
101+
"mcpServers": {
102+
"qrz": {
103+
"command": "qrz-mcp"
104+
}
105+
}
106+
}
107+
```
108+
109+
#### VS Code / GitHub Copilot
110+
111+
Add to `.vscode/mcp.json` in your workspace:
112+
113+
```json
114+
{
115+
"servers": {
116+
"qrz": {
117+
"command": "qrz-mcp"
118+
}
119+
}
120+
}
121+
```
122+
123+
#### Gemini CLI
124+
125+
Add to `~/.gemini/settings.json` (global) or `.gemini/settings.json` (project):
126+
127+
```json
128+
{
129+
"mcpServers": {
130+
"qrz": {
131+
"command": "qrz-mcp"
132+
}
133+
}
134+
}
135+
```
136+
137+
### 3. Ask questions
138+
139+
> "Look up W1AW on QRZ — what's their grid and license class?"
140+
141+
> "What DXCC entity is VP8PJ?"
142+
143+
> "How many QSOs do I have in my QRZ logbook?"
144+
145+
> "Show me all 20m FT8 QSOs from my QRZ logbook this year"
146+
147+
## Rate Limiting
148+
149+
QRZ enforces undocumented rate limits that can trigger **24-hour IP bans**. qrz-mcp protects you:
150+
151+
- 500ms minimum delay between all API calls
152+
- Token bucket: 35 requests/minute
153+
- 60s freeze on authentication failures
154+
- 3600s freeze on connection refused (IP ban detection)
155+
- In-memory response cache (5 min for callsigns, 1 hour for DXCC)
156+
157+
## Testing Without Credentials
158+
159+
Set the mock environment variable to test all 4 tools without QRZ credentials:
160+
161+
```bash
162+
QRZ_MCP_MOCK=1 qrz-mcp
163+
```
164+
165+
## MCP Inspector
166+
167+
```bash
168+
qrz-mcp --transport streamable-http --port 8002
169+
```
170+
171+
Then open the MCP Inspector at `http://localhost:8002`.
172+
173+
## Development
174+
175+
```bash
176+
git clone https://github.com/qso-graph/qrz-mcp.git
177+
cd qrz-mcp
178+
pip install -e .
179+
```
180+
181+
## QRZ Subscription Tiers
182+
183+
| Feature | Free | XML Data ($35.95/yr) |
184+
|---------|------|---------------------|
185+
| Callsign lookups/day | 100 | Unlimited |
186+
| Fields returned | Name + address only | All (grid, lat/lon, DXCC, class, QSL, image) |
187+
| Logbook API | No | Yes |
188+
| DXCC lookup | No | Yes |
189+
190+
## License
191+
192+
GPL-3.0-or-later

pyproject.toml

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
[project]
2+
name = "qrz-mcp"
3+
version = "0.1.0"
4+
description = "MCP server for QRZ.com — callsign lookup, DXCC resolution, logbook queries"
5+
readme = "README.md"
6+
license = {text = "GPL-3.0-or-later"}
7+
authors = [{name = "Greg Beam, KI7MT"}]
8+
requires-python = ">=3.10"
9+
dependencies = [
10+
"adif-mcp>=0.6.2",
11+
"fastmcp>=3.0",
12+
]
13+
keywords = [
14+
"amateur-radio", "ham-radio", "qrz", "callsign", "dxcc",
15+
"logbook", "mcp", "model-context-protocol",
16+
]
17+
classifiers = [
18+
"Development Status :: 3 - Alpha",
19+
"Intended Audience :: Developers",
20+
"Topic :: Communications :: Ham Radio",
21+
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
22+
"Programming Language :: Python :: 3",
23+
"Programming Language :: Python :: 3.10",
24+
"Programming Language :: Python :: 3.11",
25+
"Programming Language :: Python :: 3.12",
26+
"Programming Language :: Python :: 3.13",
27+
"Operating System :: OS Independent",
28+
]
29+
30+
[project.urls]
31+
Homepage = "https://qso-graph.io/"
32+
Repository = "https://github.com/qso-graph/qrz-mcp"
33+
Issues = "https://github.com/qso-graph/qrz-mcp/issues"
34+
35+
[project.scripts]
36+
qrz-mcp = "qrz_mcp.server:main"
37+
38+
[build-system]
39+
requires = ["hatchling"]
40+
build-backend = "hatchling.build"
41+
42+
[tool.hatch.build.targets.wheel]
43+
packages = ["src/qrz_mcp"]

src/qrz_mcp/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
"""qrz-mcp: MCP server for QRZ.com callsign and logbook data."""
2+
3+
from __future__ import annotations
4+
5+
try:
6+
from importlib.metadata import version
7+
8+
__version__ = version("qrz-mcp")
9+
except Exception:
10+
__version__ = "0.0.0-dev"

src/qrz_mcp/cache.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
"""Thread-safe in-memory TTL cache for API responses."""
2+
3+
from __future__ import annotations
4+
5+
import threading
6+
import time
7+
from typing import Any
8+
9+
10+
class TTLCache:
11+
"""Simple dictionary cache with per-entry TTL.
12+
13+
Thread-safe via a single lock. Expired entries are lazily evicted on access.
14+
"""
15+
16+
def __init__(self) -> None:
17+
self._store: dict[str, tuple[float, Any]] = {}
18+
self._lock = threading.Lock()
19+
20+
def get(self, key: str) -> Any | None:
21+
"""Return cached value or None if missing/expired."""
22+
with self._lock:
23+
entry = self._store.get(key)
24+
if entry is None:
25+
return None
26+
expires, value = entry
27+
if time.monotonic() > expires:
28+
del self._store[key]
29+
return None
30+
return value
31+
32+
def set(self, key: str, value: Any, ttl: float) -> None:
33+
"""Store a value with TTL in seconds."""
34+
with self._lock:
35+
self._store[key] = (time.monotonic() + ttl, value)
36+
37+
def clear(self) -> None:
38+
"""Drop all entries."""
39+
with self._lock:
40+
self._store.clear()

0 commit comments

Comments
 (0)