eqsl-mcp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
eqsl_mcp/__init__.py ADDED
@@ -0,0 +1,10 @@
1
+ """eqsl-mcp: MCP server for eQSL.cc confirmation data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ try:
6
+ from importlib.metadata import version
7
+
8
+ __version__ = version("eqsl-mcp")
9
+ except Exception:
10
+ __version__ = "0.0.0-dev"
eqsl_mcp/ag_cache.py ADDED
@@ -0,0 +1,72 @@
1
+ """AG (Authenticity Guaranteed) member list with file-based cache."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import time
7
+ import urllib.request
8
+
9
+ _AG_URL = "https://www.eqsl.cc/qslcard/DownloadedFiles/AGMemberList.txt"
10
+ _CACHE_TTL = 4 * 3600 # 4 hours — list updated ~6x daily
11
+
12
+
13
+ def _cache_path() -> str:
14
+ """Return OS-appropriate cache file path."""
15
+ if os.name == "nt":
16
+ base = os.environ.get("LOCALAPPDATA", os.path.expanduser("~"))
17
+ else:
18
+ base = os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache"))
19
+ d = os.path.join(base, "eqsl-mcp")
20
+ os.makedirs(d, exist_ok=True)
21
+ return os.path.join(d, "ag_members.txt")
22
+
23
+
24
+ def _is_fresh(path: str) -> bool:
25
+ """True if the cache file exists and is younger than TTL."""
26
+ try:
27
+ return (time.time() - os.path.getmtime(path)) < _CACHE_TTL
28
+ except OSError:
29
+ return False
30
+
31
+
32
+ def _download_ag_list() -> set[str]:
33
+ """Fetch the AG member list from eQSL and cache it."""
34
+ path = _cache_path()
35
+ req = urllib.request.Request(_AG_URL, method="GET")
36
+ with urllib.request.urlopen(req, timeout=30) as resp:
37
+ data = resp.read().decode("utf-8", errors="replace")
38
+ with open(path, "w", encoding="utf-8") as f:
39
+ f.write(data)
40
+ return _parse_ag(data)
41
+
42
+
43
+ def _parse_ag(text: str) -> set[str]:
44
+ """Parse the AG list into a set of uppercase callsigns."""
45
+ calls: set[str] = set()
46
+ for line in text.splitlines():
47
+ line = line.strip()
48
+ # Skip header/blank lines — callsigns are alphanumeric+slash
49
+ if not line or line.startswith("#") or line.startswith("List"):
50
+ continue
51
+ # Each line is one callsign
52
+ call = line.split()[0].upper()
53
+ if call.isascii() and len(call) >= 3:
54
+ calls.add(call)
55
+ return calls
56
+
57
+
58
+ def load_ag_set() -> set[str]:
59
+ """Return the AG member set, using cache if fresh."""
60
+ path = _cache_path()
61
+ if _is_fresh(path):
62
+ try:
63
+ with open(path, encoding="utf-8") as f:
64
+ return _parse_ag(f.read())
65
+ except OSError:
66
+ pass
67
+ return _download_ag_list()
68
+
69
+
70
+ def is_ag(callsign: str) -> bool:
71
+ """Check if a callsign has AG status."""
72
+ return callsign.upper() in load_ag_set()
eqsl_mcp/client.py ADDED
@@ -0,0 +1,155 @@
1
+ """HTTP client layer for eQSL.cc endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import re
7
+ import urllib.parse
8
+ import urllib.request
9
+ from typing import Any
10
+
11
+ from adif_mcp.identity import PersonaManager
12
+
13
+ from .parser import FetchResult, QsoRecord, parse_adif, to_mmddyyyy, to_qso, to_yyyymmddhhmm
14
+
15
+ _BASE = "https://www.eqsl.cc"
16
+
17
+ # Mock ADIF for testing without credentials
18
+ _MOCK_ADIF = (
19
+ "<CALL:5>KI7MT<QSO_DATE:8>20250901<TIME_ON:6>010203<BAND:3>20M<MODE:3>FT8"
20
+ "<EQSL_QSL_RCVD:1>Y<EQSL_QSLRDATE:8>20250902<APP_EQSL_AG:1>Y<EOR>"
21
+ "<CALL:5>K7ABC<QSO_DATE:8>20250901<TIME_ON:6>040506<BAND:3>40M<MODE:2>CW"
22
+ "<EQSL_QSL_RCVD:1>N<EOR>"
23
+ )
24
+
25
+ # Regex to find the .adi download link in the HTML response
26
+ _ADI_LINK_RE = re.compile(r'href="([^"]*\.adi)"', re.IGNORECASE)
27
+
28
+
29
+ def _is_mock() -> bool:
30
+ return os.getenv("EQSL_MCP_MOCK") == "1"
31
+
32
+
33
+ def _get(url: str, query: dict[str, Any] | None = None,
34
+ timeout: float = 15.0) -> tuple[int, str]:
35
+ """HTTP GET, return (status, text)."""
36
+ if query:
37
+ qs = urllib.parse.urlencode(query, doseq=True)
38
+ url = f"{url}?{qs}"
39
+ req = urllib.request.Request(url, method="GET")
40
+ with urllib.request.urlopen(req, timeout=timeout) as resp:
41
+ return resp.status, resp.read().decode("utf-8", errors="replace")
42
+
43
+
44
+ # ---------------------------------------------------------------------------
45
+ # eqsl_inbox
46
+ # ---------------------------------------------------------------------------
47
+
48
+ def download_inbox(
49
+ pm: PersonaManager,
50
+ persona: str,
51
+ since: str | None = None,
52
+ qth_nickname: str | None = None,
53
+ ) -> FetchResult:
54
+ """Download incoming eQSLs for a persona.
55
+
56
+ Two-step flow: GET DownloadInBox.cfm → parse HTML for .adi link → fetch ADIF.
57
+ Fallback: if the response body contains <EOH> or <EOR>, treat as direct ADIF.
58
+ """
59
+ if _is_mock():
60
+ sample = os.getenv("EQSL_MCP_ADIF")
61
+ if sample and os.path.exists(sample):
62
+ text = open(sample, encoding="utf-8").read()
63
+ else:
64
+ text = _MOCK_ADIF
65
+ return FetchResult(records=[to_qso(r) for r in parse_adif(text)])
66
+
67
+ username, password = pm.require(persona, "eqsl")
68
+
69
+ query: dict[str, Any] = {
70
+ "UserName": username,
71
+ "Password": password,
72
+ "RcvdSince": to_yyyymmddhhmm(since),
73
+ }
74
+ if qth_nickname:
75
+ query["QTHNickname"] = qth_nickname
76
+
77
+ status, body = _get(f"{_BASE}/qslcard/DownloadInBox.cfm", query)
78
+ if status != 200:
79
+ return FetchResult(records=[])
80
+
81
+ # Check if this is already ADIF (some responses come direct)
82
+ upper = body.upper()
83
+ if "<EOH>" in upper or "<EOR>" in upper:
84
+ return FetchResult(records=[to_qso(r) for r in parse_adif(body)])
85
+
86
+ # Two-step: extract .adi link from HTML
87
+ m = _ADI_LINK_RE.search(body)
88
+ if not m:
89
+ return FetchResult(records=[])
90
+
91
+ adi_url = m.group(1)
92
+ if not adi_url.startswith("http"):
93
+ adi_url = f"{_BASE}{adi_url}" if adi_url.startswith("/") else f"{_BASE}/{adi_url}"
94
+
95
+ _, adif_text = _get(adi_url)
96
+ return FetchResult(records=[to_qso(r) for r in parse_adif(adif_text)])
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # eqsl_verify
101
+ # ---------------------------------------------------------------------------
102
+
103
+ def verify_qso(
104
+ from_call: str,
105
+ to_call: str,
106
+ band: str,
107
+ qso_date: str,
108
+ mode: str | None = None,
109
+ ) -> dict[str, Any]:
110
+ """Verify a QSO exists in eQSL (public, no auth).
111
+
112
+ Args:
113
+ qso_date: YYYY-MM-DD format.
114
+ """
115
+ if _is_mock():
116
+ return {"verified": True, "ag": True, "message": "Result - QSO on file for AG member"}
117
+
118
+ query: dict[str, Any] = {
119
+ "Callsign": from_call.upper(),
120
+ "HisCall": to_call.upper(),
121
+ "QSOBand": band.upper(),
122
+ "QSODate": to_mmddyyyy(qso_date),
123
+ }
124
+ if mode:
125
+ query["QSOMode"] = mode.upper()
126
+
127
+ _, body = _get(f"{_BASE}/qslcard/VerifyQSO.cfm", query)
128
+
129
+ text = body.strip()
130
+ verified = "qso on file" in text.lower()
131
+ ag = "ag member" in text.lower()
132
+ return {"verified": verified, "ag": ag, "message": text}
133
+
134
+
135
+ # ---------------------------------------------------------------------------
136
+ # eqsl_last_upload
137
+ # ---------------------------------------------------------------------------
138
+
139
+ def last_upload_date(pm: PersonaManager, persona: str) -> dict[str, Any]:
140
+ """Get the last upload date for a persona."""
141
+ if _is_mock():
142
+ return {"persona": persona, "last_upload": "2026-03-01 12:00:00"}
143
+
144
+ username, password = pm.require(persona, "eqsl")
145
+
146
+ query: dict[str, Any] = {
147
+ "UserName": username,
148
+ "Password": password,
149
+ }
150
+ status, body = _get(f"{_BASE}/qslcard/DisplayLastUploadDate.cfm", query)
151
+ if status != 200:
152
+ return {"persona": persona, "last_upload": None, "error": f"HTTP {status}"}
153
+
154
+ # The response is typically plain text with the date
155
+ return {"persona": persona, "last_upload": body.strip()}
eqsl_mcp/parser.py ADDED
@@ -0,0 +1,144 @@
1
+ """Minimal ADIF parser and QSO record types for eQSL responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from datetime import date, datetime
8
+ from typing import TypedDict
9
+
10
+ # ---------------------------------------------------------------------------
11
+ # Types
12
+ # ---------------------------------------------------------------------------
13
+
14
+
15
+ class QsoRecord(TypedDict, total=False):
16
+ """Normalized QSO record from eQSL inbox."""
17
+
18
+ call: str
19
+ qso_date: str # YYYYMMDD
20
+ time_on: str # HHMM or HHMMSS
21
+ band: str | None
22
+ mode: str | None
23
+ freq: float | None # MHz
24
+ rst_sent: str | None
25
+ rst_rcvd: str | None
26
+ gridsquare: str | None
27
+ eqsl_qsl_rcvd: str | None # Y/N/I/…
28
+ eqsl_qslrdate: str | None # YYYYMMDD
29
+ app_eqsl_ag: str | None # Y/N
30
+ qslmsg: str | None
31
+ adif: dict[str, str]
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class FetchResult:
36
+ """Container for parsed inbox records."""
37
+
38
+ records: list[QsoRecord]
39
+
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # ADIF parser
43
+ # ---------------------------------------------------------------------------
44
+
45
+ _FIELD_RE = re.compile(
46
+ r"<([A-Za-z0-9_]+):(\d+)(?::[A-Za-z])?>([^<]*)", re.IGNORECASE
47
+ )
48
+
49
+
50
+ def parse_adif(text: str) -> list[dict[str, str]]:
51
+ """Extract tag→value pairs per <EOR> from raw ADIF text.
52
+
53
+ Not a full ADIF parser — sufficient for eQSL response payloads.
54
+ """
55
+ out: list[dict[str, str]] = []
56
+ current: dict[str, str] = {}
57
+ i = 0
58
+ n = len(text)
59
+ while i < n:
60
+ if text[i : i + 5].upper() == "<EOR>":
61
+ if current:
62
+ out.append(current)
63
+ current = {}
64
+ i += 5
65
+ continue
66
+ m = _FIELD_RE.match(text, i)
67
+ if not m:
68
+ i += 1
69
+ continue
70
+ tag, length_s, value = m.group(1), m.group(2), m.group(3)
71
+ try:
72
+ length = int(length_s)
73
+ except ValueError:
74
+ length = len(value)
75
+ if len(value) < length:
76
+ end = m.end()
77
+ need = length - len(value)
78
+ value = value + text[end : end + need]
79
+ i = end + need
80
+ else:
81
+ i = m.end()
82
+ current[tag.upper()] = value
83
+ if current:
84
+ out.append(current)
85
+ return out
86
+
87
+
88
+ def to_qso(rec: dict[str, str]) -> QsoRecord:
89
+ """Convert a raw ADIF tag dict into a normalized QsoRecord."""
90
+
91
+ def _float(s: str | None) -> float | None:
92
+ try:
93
+ return float(s) if s else None
94
+ except Exception:
95
+ return None
96
+
97
+ return QsoRecord(
98
+ call=rec.get("CALL", "").upper(),
99
+ qso_date=rec.get("QSO_DATE", ""),
100
+ time_on=rec.get("TIME_ON", ""),
101
+ band=rec.get("BAND"),
102
+ mode=rec.get("MODE"),
103
+ freq=_float(rec.get("FREQ")),
104
+ rst_sent=rec.get("RST_SENT"),
105
+ rst_rcvd=rec.get("RST_RCVD"),
106
+ gridsquare=rec.get("GRIDSQUARE"),
107
+ eqsl_qsl_rcvd=rec.get("EQSL_QSL_RCVD"),
108
+ eqsl_qslrdate=rec.get("EQSL_QSLRDATE"),
109
+ app_eqsl_ag=rec.get("APP_EQSL_AG"),
110
+ qslmsg=rec.get("QSLMSG"),
111
+ adif=rec,
112
+ )
113
+
114
+
115
+ # ---------------------------------------------------------------------------
116
+ # Date helpers
117
+ # ---------------------------------------------------------------------------
118
+
119
+
120
+ def to_yyyymmddhhmm(d: str | date | None) -> str:
121
+ """Convert a date to YYYYMMDDHHMM (eQSL RcvdSince format).
122
+
123
+ Accepts ISO date strings (YYYY-MM-DD), date objects, or None (→ 30 days ago).
124
+ """
125
+ if d is None:
126
+ dt = datetime.utcnow()
127
+ from datetime import timedelta
128
+ dt = dt - timedelta(days=30)
129
+ return dt.strftime("%Y%m%d0000")
130
+ if isinstance(d, date):
131
+ return d.strftime("%Y%m%d0000")
132
+ try:
133
+ return date.fromisoformat(d).strftime("%Y%m%d0000")
134
+ except Exception:
135
+ return datetime.utcnow().strftime("%Y%m%d0000")
136
+
137
+
138
+ def to_mmddyyyy(d: str) -> str:
139
+ """Convert YYYY-MM-DD to MM/DD/YYYY (eQSL VerifyQSO format)."""
140
+ try:
141
+ dt = date.fromisoformat(d)
142
+ return dt.strftime("%m/%d/%Y")
143
+ except Exception:
144
+ return d
eqsl_mcp/server.py ADDED
@@ -0,0 +1,162 @@
1
+ """eqsl-mcp: MCP server for eQSL.cc confirmation data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from fastmcp import FastMCP
8
+
9
+ from adif_mcp.identity import PersonaManager
10
+ from adif_mcp.identity.errors import CredentialError
11
+
12
+ from . import __version__
13
+ from .ag_cache import is_ag
14
+ from .client import download_inbox, last_upload_date, verify_qso
15
+
16
+ mcp = FastMCP(
17
+ "eqsl-mcp",
18
+ version=__version__,
19
+ instructions="MCP server for eQSL.cc — inbox, verification, AG status",
20
+ )
21
+
22
+
23
+ def _pm() -> PersonaManager:
24
+ return PersonaManager()
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Tools
29
+ # ---------------------------------------------------------------------------
30
+
31
+
32
+ @mcp.tool()
33
+ def eqsl_inbox(
34
+ persona: str,
35
+ since: str | None = None,
36
+ confirmed_only: bool = False,
37
+ unconfirmed_only: bool = False,
38
+ qth_nickname: str | None = None,
39
+ ) -> dict:
40
+ """Download incoming eQSLs (confirmations others have sent you).
41
+
42
+ Args:
43
+ persona: Persona name configured in adif-mcp.
44
+ since: Only records added since this date (YYYY-MM-DD). Default: last 30 days.
45
+ confirmed_only: Only return records you have confirmed back.
46
+ unconfirmed_only: Only return records you have NOT confirmed.
47
+ qth_nickname: QTH profile name (for multi-QTH callsigns).
48
+
49
+ Returns:
50
+ Total count, confirmed count, breakdown by band, and QSO records.
51
+ """
52
+ try:
53
+ result = download_inbox(_pm(), persona, since=since, qth_nickname=qth_nickname)
54
+ except CredentialError as e:
55
+ return {"error": str(e)}
56
+
57
+ records = result.records
58
+
59
+ if confirmed_only:
60
+ records = [r for r in records if (r.get("eqsl_qsl_rcvd") or "").upper() == "Y"]
61
+ elif unconfirmed_only:
62
+ records = [r for r in records if (r.get("eqsl_qsl_rcvd") or "").upper() != "Y"]
63
+
64
+ # Band tally
65
+ tally: dict[str, int] = {}
66
+ confirmed = 0
67
+ for r in records:
68
+ band = (r.get("band") or "UNKNOWN").upper()
69
+ tally[band] = tally.get(band, 0) + 1
70
+ if (r.get("eqsl_qsl_rcvd") or "").upper() == "Y":
71
+ confirmed += 1
72
+
73
+ return {
74
+ "total": len(records),
75
+ "confirmed": confirmed,
76
+ "by_band": [{"band": k, "count": v} for k, v in sorted(tally.items())],
77
+ "records": records,
78
+ }
79
+
80
+
81
+ @mcp.tool()
82
+ def eqsl_verify(
83
+ from_call: str,
84
+ to_call: str,
85
+ band: str,
86
+ qso_date: str,
87
+ mode: str | None = None,
88
+ ) -> dict:
89
+ """Check if a specific QSO exists in eQSL (public, no auth required).
90
+
91
+ Args:
92
+ from_call: Sender's callsign.
93
+ to_call: Receiver's callsign.
94
+ band: Band (e.g., '20m').
95
+ qso_date: QSO date in YYYY-MM-DD format.
96
+ mode: Mode (exact match — use 'USB' not 'SSB', 'PSK31' not 'PSK').
97
+
98
+ Returns:
99
+ Whether the QSO is verified, AG status, and the raw message.
100
+ """
101
+ return verify_qso(from_call, to_call, band, qso_date, mode)
102
+
103
+
104
+ @mcp.tool()
105
+ def eqsl_ag_check(callsign: str) -> dict:
106
+ """Check if a callsign has Authenticity Guaranteed (AG) status on eQSL.
107
+
108
+ Public, no auth required. Uses a cached copy of the AG member list
109
+ (refreshed every 4 hours).
110
+
111
+ Args:
112
+ callsign: The callsign to check.
113
+
114
+ Returns:
115
+ Callsign and AG status.
116
+ """
117
+ try:
118
+ ag = is_ag(callsign)
119
+ return {"callsign": callsign.upper(), "ag": ag}
120
+ except Exception as e:
121
+ return {"callsign": callsign.upper(), "ag": None, "error": str(e)}
122
+
123
+
124
+ @mcp.tool()
125
+ def eqsl_last_upload(persona: str) -> dict:
126
+ """Check when a persona last uploaded QSOs to eQSL.
127
+
128
+ Args:
129
+ persona: Persona name configured in adif-mcp.
130
+
131
+ Returns:
132
+ Persona name and last upload timestamp.
133
+ """
134
+ try:
135
+ return last_upload_date(_pm(), persona)
136
+ except CredentialError as e:
137
+ return {"error": str(e)}
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Entry point
142
+ # ---------------------------------------------------------------------------
143
+
144
+
145
+ def main() -> None:
146
+ """Run the eqsl-mcp server."""
147
+ transport = "stdio"
148
+ port = 8001
149
+ for i, arg in enumerate(sys.argv[1:], 1):
150
+ if arg == "--transport" and i < len(sys.argv) - 1:
151
+ transport = sys.argv[i + 1]
152
+ if arg == "--port" and i < len(sys.argv) - 1:
153
+ port = int(sys.argv[i + 1])
154
+
155
+ if transport == "streamable-http":
156
+ mcp.run(transport=transport, port=port)
157
+ else:
158
+ mcp.run(transport="stdio")
159
+
160
+
161
+ if __name__ == "__main__":
162
+ main()
@@ -0,0 +1,209 @@
1
+ Metadata-Version: 2.4
2
+ Name: eqsl-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for eQSL.cc — inbox download, QSO verification, AG status checks
5
+ Project-URL: Homepage, https://qso-graph.io/
6
+ Project-URL: Repository, https://github.com/qso-graph/eqsl-mcp
7
+ Project-URL: Issues, https://github.com/qso-graph/eqsl-mcp/issues
8
+ Author: Greg Beam, KI7MT
9
+ License: GPL-3.0-or-later
10
+ License-File: LICENSE
11
+ Keywords: amateur-radio,confirmation,eqsl,ham-radio,mcp,model-context-protocol,qsl,qso
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Communications :: Ham Radio
22
+ Requires-Python: >=3.10
23
+ Requires-Dist: adif-mcp>=0.6.2
24
+ Requires-Dist: fastmcp>=3.0
25
+ Description-Content-Type: text/markdown
26
+
27
+ # eqsl-mcp
28
+
29
+ MCP server for [eQSL.cc](https://www.eqsl.cc/) — download incoming eQSLs, verify QSOs, check AG status, and query upload history through any MCP-compatible AI assistant.
30
+
31
+ 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.
32
+
33
+ ## Install
34
+
35
+ ```bash
36
+ pip install eqsl-mcp
37
+ ```
38
+
39
+ ## Tools
40
+
41
+ | Tool | Auth | Description |
42
+ |------|------|-------------|
43
+ | `eqsl_inbox` | Yes | Download incoming eQSLs with date/confirmation filters |
44
+ | `eqsl_verify` | No | Check if a specific QSO exists in eQSL |
45
+ | `eqsl_ag_check` | No | Check if a callsign has AG (Authenticity Guaranteed) status |
46
+ | `eqsl_last_upload` | No | When did a persona last upload to eQSL |
47
+
48
+ ## Quick Start
49
+
50
+ ### 1. Set up credentials
51
+
52
+ eqsl-mcp uses adif-mcp personas for credential management:
53
+
54
+ ```bash
55
+ # Install adif-mcp if you haven't
56
+ pip install adif-mcp
57
+
58
+ # Create a persona and add eQSL credentials
59
+ adif-mcp persona create ki7mt --callsign KI7MT
60
+ adif-mcp persona provider ki7mt eqsl --username KI7MT
61
+ adif-mcp persona secret ki7mt eqsl
62
+ ```
63
+
64
+ ### 2. Configure your MCP client
65
+
66
+ eqsl-mcp works with any MCP-compatible client. Add the server config and restart — tools appear automatically.
67
+
68
+ #### Claude Desktop
69
+
70
+ Add to `claude_desktop_config.json` (`~/Library/Application Support/Claude/` on macOS, `%APPDATA%\Claude\` on Windows):
71
+
72
+ ```json
73
+ {
74
+ "mcpServers": {
75
+ "eqsl": {
76
+ "command": "eqsl-mcp"
77
+ }
78
+ }
79
+ }
80
+ ```
81
+
82
+ #### Claude Code
83
+
84
+ Add to `.claude/settings.json`:
85
+
86
+ ```json
87
+ {
88
+ "mcpServers": {
89
+ "eqsl": {
90
+ "command": "eqsl-mcp"
91
+ }
92
+ }
93
+ }
94
+ ```
95
+
96
+ #### ChatGPT Desktop
97
+
98
+ 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:
99
+
100
+ ```json
101
+ {
102
+ "mcpServers": {
103
+ "eqsl": {
104
+ "command": "eqsl-mcp"
105
+ }
106
+ }
107
+ }
108
+ ```
109
+
110
+ #### Cursor
111
+
112
+ Add to `.cursor/mcp.json` (project-level) or `~/.cursor/mcp.json` (global):
113
+
114
+ ```json
115
+ {
116
+ "mcpServers": {
117
+ "eqsl": {
118
+ "command": "eqsl-mcp"
119
+ }
120
+ }
121
+ }
122
+ ```
123
+
124
+ #### VS Code / GitHub Copilot
125
+
126
+ Add to `.vscode/mcp.json` in your workspace:
127
+
128
+ ```json
129
+ {
130
+ "servers": {
131
+ "eqsl": {
132
+ "command": "eqsl-mcp"
133
+ }
134
+ }
135
+ }
136
+ ```
137
+
138
+ #### Gemini CLI
139
+
140
+ Add to `~/.gemini/settings.json` (global) or `.gemini/settings.json` (project):
141
+
142
+ ```json
143
+ {
144
+ "mcpServers": {
145
+ "eqsl": {
146
+ "command": "eqsl-mcp"
147
+ }
148
+ }
149
+ }
150
+ ```
151
+
152
+ ### 3. Ask questions
153
+
154
+ > "Show me all eQSLs received this week"
155
+
156
+ > "How many unconfirmed eQSLs do I have on 20m FT8?"
157
+
158
+ > "Does W1AW have AG status on eQSL?"
159
+
160
+ > "Verify my QSO with KI7MT on 20m on March 1, 2026"
161
+
162
+ ## Testing Without Credentials
163
+
164
+ The two public tools (`eqsl_verify` and `eqsl_ag_check`) work without any credentials.
165
+
166
+ For `eqsl_inbox` testing, set the mock environment variable:
167
+
168
+ ```bash
169
+ EQSL_MCP_MOCK=1 eqsl-mcp
170
+ ```
171
+
172
+ Or point to a local ADIF file:
173
+
174
+ ```bash
175
+ EQSL_MCP_MOCK=1 EQSL_MCP_ADIF=/path/to/test.adi eqsl-mcp
176
+ ```
177
+
178
+ ## MCP Inspector
179
+
180
+ ```bash
181
+ eqsl-mcp --transport streamable-http --port 8001
182
+ ```
183
+
184
+ Then open the MCP Inspector at `http://localhost:8001`.
185
+
186
+ ## Development
187
+
188
+ ```bash
189
+ git clone https://github.com/qso-graph/eqsl-mcp.git
190
+ cd eqsl-mcp
191
+ pip install -e .
192
+ ```
193
+
194
+ ## Date Formats
195
+
196
+ eQSL uses different date formats across endpoints. eqsl-mcp normalizes everything — you always use `YYYY-MM-DD`:
197
+
198
+ | You provide | eqsl-mcp sends | Endpoint |
199
+ |-------------|----------------|----------|
200
+ | `2026-03-01` | `202603010000` | DownloadInBox (RcvdSince) |
201
+ | `2026-03-01` | `03/01/2026` | VerifyQSO (QSODate) |
202
+
203
+ ## Mode Matching
204
+
205
+ eQSL requires exact mode matching. `SSB` won't match `USB`/`LSB`. `PSK` won't match `PSK31`. Use the exact mode logged by the other station.
206
+
207
+ ## License
208
+
209
+ GPL-3.0-or-later
@@ -0,0 +1,10 @@
1
+ eqsl_mcp/__init__.py,sha256=6NK0DGRHMov5kL0faJFbY296Qpm-3vVvNzWeM0wXUTU,230
2
+ eqsl_mcp/ag_cache.py,sha256=Rug-3b3HTmJTA-MAqIga74-EYWB950_lxRAXLuK0CIM,2249
3
+ eqsl_mcp/client.py,sha256=bdQ0sIMeTvGuZmS4J5rntwxkSdChp-NsHhaOIy0Vu2s,4973
4
+ eqsl_mcp/parser.py,sha256=UAVVYV6hviqNuD2jVpzNkaOmkQyaeXlb02ppi3owqZs,4147
5
+ eqsl_mcp/server.py,sha256=NC03Iu4uIcu6qo6AKtiYlRFBIZp6BXmS9k2EJq1AoXI,4525
6
+ eqsl_mcp-0.1.0.dist-info/METADATA,sha256=G6yoQt8YOE-AEEhofI_vAIyw4Mp5aypG_GW0xasRt3w,4857
7
+ eqsl_mcp-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
8
+ eqsl_mcp-0.1.0.dist-info/entry_points.txt,sha256=fTB5m-iDRBATEm4MoRhsedICc_tX9G84OI56-_y7MsI,50
9
+ eqsl_mcp-0.1.0.dist-info/licenses/LICENSE,sha256=rI0DhJQdcES8AB6A6Jf1ZD2d43RNo1UAZMK85cNNRoI,1025
10
+ eqsl_mcp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ eqsl-mcp = eqsl_mcp.server:main
@@ -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>