wspr-mcp 0.1.0__tar.gz

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.
@@ -0,0 +1,25 @@
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
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: actions/setup-python@v5
18
+ with:
19
+ python-version: "3.12"
20
+ - name: Install build dependencies
21
+ run: pip install build
22
+ - name: Build package
23
+ run: python -m build
24
+ - name: Publish to PyPI
25
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,6 @@
1
+ __pycache__/
2
+ *.pyc
3
+ *.egg-info/
4
+ dist/
5
+ build/
6
+ .venv/
wspr_mcp-0.1.0/LICENSE ADDED
@@ -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>
@@ -0,0 +1,174 @@
1
+ Metadata-Version: 2.4
2
+ Name: wspr-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for WSPR beacon data analytics — band openings, path analysis, solar correlation
5
+ Project-URL: Homepage, https://qso-graph.io/
6
+ Project-URL: Repository, https://github.com/qso-graph/wspr-mcp
7
+ Project-URL: Issues, https://github.com/qso-graph/wspr-mcp/issues
8
+ Author: Greg Beam, KI7MT
9
+ License: GPL-3.0-or-later
10
+ License-File: LICENSE
11
+ Keywords: amateur-radio,band-openings,beacon,ham-radio,hf,mcp,model-context-protocol,propagation,wspr
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: fastmcp>=3.0
24
+ Description-Content-Type: text/markdown
25
+
26
+ # wspr-mcp
27
+
28
+ MCP server for [WSPR](https://www.wsprnet.org/) (Weak Signal Propagation Reporter) beacon data — live spots, callsign activity, per-band activity, longest paths, and grid-to-grid propagation analysis through any MCP-compatible AI assistant.
29
+
30
+ Part of the [qso-graph](https://qso-graph.io/) project. **No authentication required** — all public data.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install wspr-mcp
36
+ ```
37
+
38
+ ## Tools
39
+
40
+ | Tool | Description |
41
+ |------|-------------|
42
+ | `wspr_spots` | Recent WSPR spots with callsign/band filters |
43
+ | `wspr_activity` | TX/RX activity summary for a callsign |
44
+ | `wspr_band_activity` | Per-band spot counts, station counts, and average distance |
45
+ | `wspr_top_paths` | Longest/best WSPR paths in the last 24 hours |
46
+ | `wspr_propagation` | WSPR-derived propagation between two grid squares |
47
+
48
+ ## What is WSPR?
49
+
50
+ WSPR beacons transmit a 2-minute encoded signal at very low power (typically 200 mW to 5 W). Each decoded spot proves a propagation path exists between two locations on a specific band. With thousands of beacons worldwide transmitting 24/7, WSPR provides continuous, automated propagation monitoring across all HF bands.
51
+
52
+ ## Quick Start
53
+
54
+ No credentials needed — just install and configure your MCP client.
55
+
56
+ ### Configure your MCP client
57
+
58
+ #### Claude Desktop
59
+
60
+ Add to `claude_desktop_config.json` (`~/Library/Application Support/Claude/` on macOS, `%APPDATA%\Claude\` on Windows):
61
+
62
+ ```json
63
+ {
64
+ "mcpServers": {
65
+ "wspr": {
66
+ "command": "wspr-mcp"
67
+ }
68
+ }
69
+ }
70
+ ```
71
+
72
+ #### Claude Code
73
+
74
+ Add to `.claude/settings.json`:
75
+
76
+ ```json
77
+ {
78
+ "mcpServers": {
79
+ "wspr": {
80
+ "command": "wspr-mcp"
81
+ }
82
+ }
83
+ }
84
+ ```
85
+
86
+ #### ChatGPT Desktop
87
+
88
+ ```json
89
+ {
90
+ "mcpServers": {
91
+ "wspr": {
92
+ "command": "wspr-mcp"
93
+ }
94
+ }
95
+ }
96
+ ```
97
+
98
+ #### Cursor
99
+
100
+ Add to `.cursor/mcp.json` (project-level) or `~/.cursor/mcp.json` (global):
101
+
102
+ ```json
103
+ {
104
+ "mcpServers": {
105
+ "wspr": {
106
+ "command": "wspr-mcp"
107
+ }
108
+ }
109
+ }
110
+ ```
111
+
112
+ #### VS Code / GitHub Copilot
113
+
114
+ Add to `.vscode/mcp.json` in your workspace:
115
+
116
+ ```json
117
+ {
118
+ "servers": {
119
+ "wspr": {
120
+ "command": "wspr-mcp"
121
+ }
122
+ }
123
+ }
124
+ ```
125
+
126
+ #### Gemini CLI
127
+
128
+ Add to `~/.gemini/settings.json` (global) or `.gemini/settings.json` (project):
129
+
130
+ ```json
131
+ {
132
+ "mcpServers": {
133
+ "wspr": {
134
+ "command": "wspr-mcp"
135
+ }
136
+ }
137
+ }
138
+ ```
139
+
140
+ ### Ask questions
141
+
142
+ > "Show me recent WSPR spots on 20m"
143
+
144
+ > "What's KI7MT's WSPR activity?"
145
+
146
+ > "Which bands have the most WSPR activity right now?"
147
+
148
+ > "What are the longest WSPR paths in the last 24 hours?"
149
+
150
+ > "Is there propagation between Idaho (DN13) and central Europe (JN48)?"
151
+
152
+ ## Testing Without Network
153
+
154
+ ```bash
155
+ WSPR_MCP_MOCK=1 wspr-mcp
156
+ ```
157
+
158
+ ## MCP Inspector
159
+
160
+ ```bash
161
+ wspr-mcp --transport streamable-http --port 8009
162
+ ```
163
+
164
+ ## Development
165
+
166
+ ```bash
167
+ git clone https://github.com/qso-graph/wspr-mcp.git
168
+ cd wspr-mcp
169
+ pip install -e .
170
+ ```
171
+
172
+ ## License
173
+
174
+ GPL-3.0-or-later
@@ -0,0 +1,149 @@
1
+ # wspr-mcp
2
+
3
+ MCP server for [WSPR](https://www.wsprnet.org/) (Weak Signal Propagation Reporter) beacon data — live spots, callsign activity, per-band activity, longest paths, and grid-to-grid propagation analysis through any MCP-compatible AI assistant.
4
+
5
+ Part of the [qso-graph](https://qso-graph.io/) project. **No authentication required** — all public data.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install wspr-mcp
11
+ ```
12
+
13
+ ## Tools
14
+
15
+ | Tool | Description |
16
+ |------|-------------|
17
+ | `wspr_spots` | Recent WSPR spots with callsign/band filters |
18
+ | `wspr_activity` | TX/RX activity summary for a callsign |
19
+ | `wspr_band_activity` | Per-band spot counts, station counts, and average distance |
20
+ | `wspr_top_paths` | Longest/best WSPR paths in the last 24 hours |
21
+ | `wspr_propagation` | WSPR-derived propagation between two grid squares |
22
+
23
+ ## What is WSPR?
24
+
25
+ WSPR beacons transmit a 2-minute encoded signal at very low power (typically 200 mW to 5 W). Each decoded spot proves a propagation path exists between two locations on a specific band. With thousands of beacons worldwide transmitting 24/7, WSPR provides continuous, automated propagation monitoring across all HF bands.
26
+
27
+ ## Quick Start
28
+
29
+ No credentials needed — just install and configure your MCP client.
30
+
31
+ ### Configure your MCP client
32
+
33
+ #### Claude Desktop
34
+
35
+ Add to `claude_desktop_config.json` (`~/Library/Application Support/Claude/` on macOS, `%APPDATA%\Claude\` on Windows):
36
+
37
+ ```json
38
+ {
39
+ "mcpServers": {
40
+ "wspr": {
41
+ "command": "wspr-mcp"
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ #### Claude Code
48
+
49
+ Add to `.claude/settings.json`:
50
+
51
+ ```json
52
+ {
53
+ "mcpServers": {
54
+ "wspr": {
55
+ "command": "wspr-mcp"
56
+ }
57
+ }
58
+ }
59
+ ```
60
+
61
+ #### ChatGPT Desktop
62
+
63
+ ```json
64
+ {
65
+ "mcpServers": {
66
+ "wspr": {
67
+ "command": "wspr-mcp"
68
+ }
69
+ }
70
+ }
71
+ ```
72
+
73
+ #### Cursor
74
+
75
+ Add to `.cursor/mcp.json` (project-level) or `~/.cursor/mcp.json` (global):
76
+
77
+ ```json
78
+ {
79
+ "mcpServers": {
80
+ "wspr": {
81
+ "command": "wspr-mcp"
82
+ }
83
+ }
84
+ }
85
+ ```
86
+
87
+ #### VS Code / GitHub Copilot
88
+
89
+ Add to `.vscode/mcp.json` in your workspace:
90
+
91
+ ```json
92
+ {
93
+ "servers": {
94
+ "wspr": {
95
+ "command": "wspr-mcp"
96
+ }
97
+ }
98
+ }
99
+ ```
100
+
101
+ #### Gemini CLI
102
+
103
+ Add to `~/.gemini/settings.json` (global) or `.gemini/settings.json` (project):
104
+
105
+ ```json
106
+ {
107
+ "mcpServers": {
108
+ "wspr": {
109
+ "command": "wspr-mcp"
110
+ }
111
+ }
112
+ }
113
+ ```
114
+
115
+ ### Ask questions
116
+
117
+ > "Show me recent WSPR spots on 20m"
118
+
119
+ > "What's KI7MT's WSPR activity?"
120
+
121
+ > "Which bands have the most WSPR activity right now?"
122
+
123
+ > "What are the longest WSPR paths in the last 24 hours?"
124
+
125
+ > "Is there propagation between Idaho (DN13) and central Europe (JN48)?"
126
+
127
+ ## Testing Without Network
128
+
129
+ ```bash
130
+ WSPR_MCP_MOCK=1 wspr-mcp
131
+ ```
132
+
133
+ ## MCP Inspector
134
+
135
+ ```bash
136
+ wspr-mcp --transport streamable-http --port 8009
137
+ ```
138
+
139
+ ## Development
140
+
141
+ ```bash
142
+ git clone https://github.com/qso-graph/wspr-mcp.git
143
+ cd wspr-mcp
144
+ pip install -e .
145
+ ```
146
+
147
+ ## License
148
+
149
+ GPL-3.0-or-later
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "wspr-mcp"
3
+ version = "0.1.0"
4
+ description = "MCP server for WSPR beacon data analytics — band openings, path analysis, solar correlation"
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
+ "fastmcp>=3.0",
11
+ ]
12
+ keywords = [
13
+ "amateur-radio", "ham-radio", "wspr", "beacon", "propagation",
14
+ "mcp", "model-context-protocol", "band-openings", "hf",
15
+ ]
16
+ classifiers = [
17
+ "Development Status :: 3 - Alpha",
18
+ "Intended Audience :: Developers",
19
+ "Topic :: Communications :: Ham Radio",
20
+ "License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.10",
23
+ "Programming Language :: Python :: 3.11",
24
+ "Programming Language :: Python :: 3.12",
25
+ "Programming Language :: Python :: 3.13",
26
+ "Operating System :: OS Independent",
27
+ ]
28
+
29
+ [project.urls]
30
+ Homepage = "https://qso-graph.io/"
31
+ Repository = "https://github.com/qso-graph/wspr-mcp"
32
+ Issues = "https://github.com/qso-graph/wspr-mcp/issues"
33
+
34
+ [project.scripts]
35
+ wspr-mcp = "wspr_mcp.server:main"
36
+
37
+ [build-system]
38
+ requires = ["hatchling"]
39
+ build-backend = "hatchling.build"
40
+
41
+ [tool.hatch.build.targets.wheel]
42
+ packages = ["src/wspr_mcp"]
@@ -0,0 +1,10 @@
1
+ """MCP server for WSPR beacon data analytics — band openings, path analysis, solar correlation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ try:
6
+ from importlib.metadata import version
7
+
8
+ __version__ = version("wspr-mcp")
9
+ except Exception:
10
+ __version__ = "0.0.0-dev"
@@ -0,0 +1,312 @@
1
+ """WSPR data client — WSPRnet spot queries and band activity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import re
8
+ import threading
9
+ import time
10
+ import urllib.parse
11
+ import urllib.request
12
+ from typing import Any
13
+
14
+ from . import __version__
15
+
16
+ _WSPR_ROCKS = "https://wspr.rocks"
17
+
18
+ # Cache TTLs
19
+ _SPOTS_TTL = 120.0 # 2 minutes
20
+ _ACTIVITY_TTL = 300.0 # 5 minutes
21
+ _BAND_TTL = 300.0 # 5 minutes
22
+ _PATHS_TTL = 600.0 # 10 minutes
23
+
24
+ # Rate limiting
25
+ _MIN_DELAY = 0.5 # WSPRnet is volunteer-run, be respectful
26
+
27
+ # Band dial frequencies (MHz) used by WSPR
28
+ _WSPR_BANDS: dict[str, float] = {
29
+ "160m": 1.8366, "80m": 3.5686, "60m": 5.2872,
30
+ "40m": 7.0386, "30m": 10.1387, "20m": 14.0956,
31
+ "17m": 18.1046, "15m": 21.0946, "12m": 24.9246,
32
+ "10m": 28.1246, "6m": 50.2930, "2m": 144.4890,
33
+ }
34
+
35
+ # Reverse: frequency range → band name
36
+ _FREQ_RANGES: list[tuple[float, float, str]] = [
37
+ (1.8, 2.0, "160m"), (3.5, 4.0, "80m"), (5.2, 5.4, "60m"),
38
+ (7.0, 7.3, "40m"), (10.1, 10.2, "30m"), (14.0, 14.4, "20m"),
39
+ (18.0, 18.2, "17m"), (21.0, 21.5, "15m"), (24.8, 25.0, "12m"),
40
+ (28.0, 29.7, "10m"), (50.0, 54.0, "6m"), (144.0, 148.0, "2m"),
41
+ ]
42
+
43
+
44
+ def _is_mock() -> bool:
45
+ return os.getenv("WSPR_MCP_MOCK") == "1"
46
+
47
+
48
+ def _freq_to_band(freq_mhz: float) -> str:
49
+ """Convert frequency in MHz to band name."""
50
+ for lo, hi, name in _FREQ_RANGES:
51
+ if lo <= freq_mhz <= hi:
52
+ return name
53
+ return f"{freq_mhz:.3f}MHz"
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # Mock data
58
+ # ---------------------------------------------------------------------------
59
+
60
+ _MOCK_SPOTS = [
61
+ {
62
+ "timestamp": "2026-03-04 21:00",
63
+ "tx_call": "KI7MT",
64
+ "tx_grid": "DN13la",
65
+ "tx_power_dbm": 23,
66
+ "rx_call": "KPH",
67
+ "rx_grid": "CM87",
68
+ "frequency": 14.097074,
69
+ "snr": -12,
70
+ "drift": 0,
71
+ "distance_km": 742,
72
+ "band": "20m",
73
+ },
74
+ {
75
+ "timestamp": "2026-03-04 21:00",
76
+ "tx_call": "K9AN",
77
+ "tx_grid": "EN50",
78
+ "tx_power_dbm": 37,
79
+ "rx_call": "G8JNJ",
80
+ "rx_grid": "IO91",
81
+ "frequency": 14.097052,
82
+ "snr": -18,
83
+ "drift": 0,
84
+ "distance_km": 6453,
85
+ "band": "20m",
86
+ },
87
+ {
88
+ "timestamp": "2026-03-04 21:02",
89
+ "tx_call": "VK6XT",
90
+ "tx_grid": "OF78",
91
+ "tx_power_dbm": 23,
92
+ "rx_call": "KI7MT",
93
+ "rx_grid": "DN13",
94
+ "frequency": 7.040088,
95
+ "snr": -24,
96
+ "drift": 1,
97
+ "distance_km": 15246,
98
+ "band": "40m",
99
+ },
100
+ ]
101
+
102
+ _MOCK_ACTIVITY = {
103
+ "callsign": "KI7MT",
104
+ "grid": "DN13la",
105
+ "tx_spots": 47,
106
+ "rx_spots": 312,
107
+ "bands_active": ["20m", "40m", "30m"],
108
+ "last_spot": "2026-03-04 21:00",
109
+ "unique_reporters": 28,
110
+ "max_distance_km": 15246,
111
+ "best_snr": -8,
112
+ }
113
+
114
+ _MOCK_BAND_ACTIVITY = {
115
+ "160m": {"spots": 234, "tx_stations": 45, "rx_stations": 89, "avg_distance_km": 1250},
116
+ "80m": {"spots": 567, "tx_stations": 112, "rx_stations": 203, "avg_distance_km": 2100},
117
+ "40m": {"spots": 1245, "tx_stations": 289, "rx_stations": 445, "avg_distance_km": 4500},
118
+ "30m": {"spots": 876, "tx_stations": 198, "rx_stations": 367, "avg_distance_km": 5200},
119
+ "20m": {"spots": 2134, "tx_stations": 456, "rx_stations": 678, "avg_distance_km": 7800},
120
+ "15m": {"spots": 432, "tx_stations": 98, "rx_stations": 167, "avg_distance_km": 9200},
121
+ "10m": {"spots": 187, "tx_stations": 34, "rx_stations": 76, "avg_distance_km": 11500},
122
+ }
123
+
124
+ _MOCK_TOP_PATHS = [
125
+ {"tx_call": "VK6XT", "tx_grid": "OF78", "rx_call": "EA8BFK", "rx_grid": "IL18",
126
+ "band": "20m", "snr": -22, "distance_km": 17482, "timestamp": "2026-03-04 20:30"},
127
+ {"tx_call": "ZL2IFB", "tx_grid": "RE66", "rx_call": "SWL-IW2DZX", "rx_grid": "JN45",
128
+ "band": "20m", "snr": -26, "distance_km": 18812, "timestamp": "2026-03-04 20:00"},
129
+ {"tx_call": "K9AN", "tx_grid": "EN50", "rx_call": "VK2KRR", "rx_grid": "QF56",
130
+ "band": "40m", "snr": -24, "distance_km": 15123, "timestamp": "2026-03-04 19:30"},
131
+ ]
132
+
133
+ _MOCK_PROPAGATION = {
134
+ "tx_grid": "DN13",
135
+ "rx_grid": "JN48",
136
+ "distance_km": 8842,
137
+ "bands": {
138
+ "20m": {"spots_24h": 12, "avg_snr": -15.2, "best_snr": -8, "hours_open": [14, 15, 16, 17, 18]},
139
+ "40m": {"spots_24h": 3, "avg_snr": -22.7, "best_snr": -19, "hours_open": [4, 5, 6]},
140
+ },
141
+ }
142
+
143
+
144
+ class WSPRClient:
145
+ """WSPR data client with caching and rate limiting."""
146
+
147
+ def __init__(self) -> None:
148
+ self._lock = threading.Lock()
149
+ self._last_request: float = 0.0
150
+ self._cache: dict[str, tuple[float, Any]] = {}
151
+
152
+ # ------------------------------------------------------------------
153
+ # Cache + HTTP
154
+ # ------------------------------------------------------------------
155
+
156
+ def _cache_get(self, key: str) -> Any | None:
157
+ entry = self._cache.get(key)
158
+ if entry is None:
159
+ return None
160
+ expires, value = entry
161
+ if time.monotonic() > expires:
162
+ del self._cache[key]
163
+ return None
164
+ return value
165
+
166
+ def _cache_set(self, key: str, value: Any, ttl: float) -> None:
167
+ self._cache[key] = (time.monotonic() + ttl, value)
168
+
169
+ def _rate_limit(self) -> None:
170
+ with self._lock:
171
+ now = time.monotonic()
172
+ elapsed = now - self._last_request
173
+ if elapsed < _MIN_DELAY:
174
+ time.sleep(_MIN_DELAY - elapsed)
175
+ self._last_request = time.monotonic()
176
+
177
+ def _get_json(self, url: str) -> Any:
178
+ self._rate_limit()
179
+ req = urllib.request.Request(url, method="GET")
180
+ req.add_header("User-Agent", f"wspr-mcp/{__version__}")
181
+ with urllib.request.urlopen(req, timeout=30) as resp:
182
+ body = resp.read().decode("utf-8", errors="replace")
183
+ if not body or body.strip() == "":
184
+ return None
185
+ return json.loads(body)
186
+
187
+ # ------------------------------------------------------------------
188
+ # Public methods
189
+ # ------------------------------------------------------------------
190
+
191
+ def spots(
192
+ self,
193
+ callsign: str = "",
194
+ band: str = "",
195
+ limit: int = 50,
196
+ ) -> list[dict[str, Any]]:
197
+ """Get recent WSPR spots, optionally filtered."""
198
+ key = f"spots:{callsign}:{band}:{limit}"
199
+ cached = self._cache_get(key)
200
+ if cached is not None:
201
+ return cached
202
+
203
+ if _is_mock():
204
+ data = list(_MOCK_SPOTS)
205
+ else:
206
+ params: dict[str, str] = {"count": str(min(limit, 200))}
207
+ if callsign:
208
+ params["call"] = callsign.upper()
209
+ if band and band in _WSPR_BANDS:
210
+ params["band"] = str(_WSPR_BANDS[band])
211
+ qs = urllib.parse.urlencode(params)
212
+ data = self._get_json(f"{_WSPR_ROCKS}/api/spots?{qs}") or []
213
+
214
+ # Normalize
215
+ spots = []
216
+ for s in data:
217
+ if isinstance(s, dict):
218
+ freq = s.get("frequency", 0)
219
+ if isinstance(freq, (int, float)):
220
+ s["band"] = _freq_to_band(freq)
221
+ spots.append(s)
222
+ data = spots
223
+
224
+ # Client-side filtering
225
+ if band and _is_mock():
226
+ data = [s for s in data if s.get("band", "").lower() == band.lower()]
227
+ if callsign and _is_mock():
228
+ call = callsign.upper()
229
+ data = [s for s in data if s.get("tx_call", "").upper() == call
230
+ or s.get("rx_call", "").upper() == call]
231
+
232
+ self._cache_set(key, data[:limit], _SPOTS_TTL)
233
+ return data[:limit]
234
+
235
+ def activity(self, callsign: str) -> dict[str, Any]:
236
+ """Get WSPR activity summary for a callsign."""
237
+ call = callsign.upper()
238
+ key = f"activity:{call}"
239
+ cached = self._cache_get(key)
240
+ if cached is not None:
241
+ return cached
242
+
243
+ if _is_mock():
244
+ data = dict(_MOCK_ACTIVITY)
245
+ else:
246
+ data = self._get_json(
247
+ f"{_WSPR_ROCKS}/api/activity/{urllib.parse.quote(call)}"
248
+ )
249
+
250
+ if not data:
251
+ return {"callsign": call, "error": "No WSPR activity found"}
252
+
253
+ self._cache_set(key, data, _ACTIVITY_TTL)
254
+ return data
255
+
256
+ def band_activity(self) -> dict[str, Any]:
257
+ """Get current per-band WSPR activity summary."""
258
+ key = "band_activity"
259
+ cached = self._cache_get(key)
260
+ if cached is not None:
261
+ return cached
262
+
263
+ if _is_mock():
264
+ data = dict(_MOCK_BAND_ACTIVITY)
265
+ else:
266
+ data = self._get_json(f"{_WSPR_ROCKS}/api/bands") or {}
267
+
268
+ self._cache_set(key, data, _BAND_TTL)
269
+ return data
270
+
271
+ def top_paths(self, band: str = "", limit: int = 20) -> list[dict[str, Any]]:
272
+ """Get longest/best WSPR paths in the last 24 hours."""
273
+ key = f"top_paths:{band}:{limit}"
274
+ cached = self._cache_get(key)
275
+ if cached is not None:
276
+ return cached
277
+
278
+ if _is_mock():
279
+ data = list(_MOCK_TOP_PATHS)
280
+ else:
281
+ params: dict[str, str] = {"count": str(min(limit, 50))}
282
+ if band:
283
+ params["band"] = band
284
+ qs = urllib.parse.urlencode(params)
285
+ data = self._get_json(f"{_WSPR_ROCKS}/api/top?{qs}") or []
286
+
287
+ if band and _is_mock():
288
+ data = [p for p in data if p.get("band", "").lower() == band.lower()]
289
+
290
+ self._cache_set(key, data[:limit], _PATHS_TTL)
291
+ return data[:limit]
292
+
293
+ def propagation(self, tx_grid: str, rx_grid: str) -> dict[str, Any]:
294
+ """Get WSPR-derived propagation between two grid squares."""
295
+ tg = tx_grid.upper()[:4]
296
+ rg = rx_grid.upper()[:4]
297
+ key = f"prop:{tg}:{rg}"
298
+ cached = self._cache_get(key)
299
+ if cached is not None:
300
+ return cached
301
+
302
+ if _is_mock():
303
+ data = dict(_MOCK_PROPAGATION)
304
+ else:
305
+ params = urllib.parse.urlencode({"tx": tg, "rx": rg})
306
+ data = self._get_json(f"{_WSPR_ROCKS}/api/propagation?{params}")
307
+
308
+ if not data:
309
+ return {"tx_grid": tg, "rx_grid": rg, "error": "No propagation data found"}
310
+
311
+ self._cache_set(key, data, _PATHS_TTL)
312
+ return data
@@ -0,0 +1,170 @@
1
+ """wspr-mcp: MCP server for WSPR beacon data analytics."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from typing import Any
7
+
8
+ from fastmcp import FastMCP
9
+
10
+ from . import __version__
11
+ from .client import WSPRClient
12
+
13
+ mcp = FastMCP(
14
+ "wspr-mcp",
15
+ version=__version__,
16
+ instructions=(
17
+ "MCP server for WSPR (Weak Signal Propagation Reporter) beacon data. "
18
+ "Live WSPR spots, callsign activity, per-band activity, longest paths, "
19
+ "and grid-to-grid propagation analysis. "
20
+ "All public data, no authentication required."
21
+ ),
22
+ )
23
+
24
+ _client: WSPRClient | None = None
25
+
26
+
27
+ def _get_client() -> WSPRClient:
28
+ global _client
29
+ if _client is None:
30
+ _client = WSPRClient()
31
+ return _client
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Tools
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ @mcp.tool()
40
+ def wspr_spots(
41
+ callsign: str = "",
42
+ band: str = "",
43
+ limit: int = 50,
44
+ ) -> dict[str, Any]:
45
+ """Get recent WSPR spots.
46
+
47
+ WSPR beacons transmit every 2 minutes on precise frequencies.
48
+ Each spot is a 2-minute integration proving a propagation path exists.
49
+
50
+ Args:
51
+ callsign: Filter by TX or RX callsign. Empty for all.
52
+ band: Filter by band (e.g., 20m, 40m). Empty for all bands.
53
+ limit: Maximum spots to return (default 50, max 200).
54
+
55
+ Returns:
56
+ List of spots with TX/RX callsigns, grids, SNR, distance, and band.
57
+ """
58
+ try:
59
+ spots = _get_client().spots(
60
+ callsign=callsign,
61
+ band=band,
62
+ limit=min(limit, 200),
63
+ )
64
+ return {"total": len(spots), "spots": spots}
65
+ except Exception as e:
66
+ return {"error": str(e)}
67
+
68
+
69
+ @mcp.tool()
70
+ def wspr_activity(callsign: str) -> dict[str, Any]:
71
+ """Get WSPR activity summary for a callsign.
72
+
73
+ Shows TX/RX spot counts, active bands, unique reporters,
74
+ maximum distance, and best SNR.
75
+
76
+ Args:
77
+ callsign: Callsign to look up (e.g., KI7MT, K9AN).
78
+
79
+ Returns:
80
+ Activity summary with spot counts, bands, reporters, and records.
81
+ """
82
+ try:
83
+ return _get_client().activity(callsign)
84
+ except Exception as e:
85
+ return {"error": str(e)}
86
+
87
+
88
+ @mcp.tool()
89
+ def wspr_band_activity() -> dict[str, Any]:
90
+ """Get current per-band WSPR activity summary.
91
+
92
+ Shows how many WSPR spots, TX stations, and RX stations are active
93
+ on each band, with average path distance. Useful for seeing which
94
+ bands are open right now.
95
+
96
+ Returns:
97
+ Per-band activity with spot counts, station counts, and average distance.
98
+ """
99
+ try:
100
+ data = _get_client().band_activity()
101
+ return {"bands": data}
102
+ except Exception as e:
103
+ return {"error": str(e)}
104
+
105
+
106
+ @mcp.tool()
107
+ def wspr_top_paths(band: str = "", limit: int = 20) -> dict[str, Any]:
108
+ """Get the longest/best WSPR paths in the last 24 hours.
109
+
110
+ WSPR's precise timing and low power make it the gold standard for
111
+ detecting marginal propagation. Long paths here prove the band is open.
112
+
113
+ Args:
114
+ band: Filter by band (e.g., 20m). Empty for all bands.
115
+ limit: Maximum paths to return (default 20).
116
+
117
+ Returns:
118
+ List of top paths with TX/RX callsigns, grids, band, SNR, and distance.
119
+ """
120
+ try:
121
+ paths = _get_client().top_paths(band=band, limit=limit)
122
+ return {"total": len(paths), "paths": paths}
123
+ except Exception as e:
124
+ return {"error": str(e)}
125
+
126
+
127
+ @mcp.tool()
128
+ def wspr_propagation(tx_grid: str, rx_grid: str) -> dict[str, Any]:
129
+ """Get WSPR-derived propagation between two grid squares.
130
+
131
+ Shows which bands have been open between two locations in the last
132
+ 24 hours, with spot counts, average SNR, best SNR, and hours of
133
+ opening. Based on actual WSPR beacon observations.
134
+
135
+ Args:
136
+ tx_grid: Transmitter grid square (e.g., DN13, FN31).
137
+ rx_grid: Receiver grid square (e.g., JN48, IO91).
138
+
139
+ Returns:
140
+ Per-band propagation data with spot counts, SNR stats, and open hours.
141
+ """
142
+ try:
143
+ return _get_client().propagation(tx_grid, rx_grid)
144
+ except Exception as e:
145
+ return {"error": str(e)}
146
+
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Entry point
150
+ # ---------------------------------------------------------------------------
151
+
152
+
153
+ def main() -> None:
154
+ """Run the wspr-mcp server."""
155
+ transport = "stdio"
156
+ port = 8009
157
+ for i, arg in enumerate(sys.argv[1:], 1):
158
+ if arg == "--transport" and i < len(sys.argv) - 1:
159
+ transport = sys.argv[i + 1]
160
+ if arg == "--port" and i < len(sys.argv) - 1:
161
+ port = int(sys.argv[i + 1])
162
+
163
+ if transport == "streamable-http":
164
+ mcp.run(transport=transport, port=port)
165
+ else:
166
+ mcp.run(transport="stdio")
167
+
168
+
169
+ if __name__ == "__main__":
170
+ main()