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.
- wspr_mcp-0.1.0/.github/workflows/publish.yml +25 -0
- wspr_mcp-0.1.0/.gitignore +6 -0
- wspr_mcp-0.1.0/LICENSE +22 -0
- wspr_mcp-0.1.0/PKG-INFO +174 -0
- wspr_mcp-0.1.0/README.md +149 -0
- wspr_mcp-0.1.0/pyproject.toml +42 -0
- wspr_mcp-0.1.0/src/wspr_mcp/__init__.py +10 -0
- wspr_mcp-0.1.0/src/wspr_mcp/client.py +312 -0
- wspr_mcp-0.1.0/src/wspr_mcp/server.py +170 -0
|
@@ -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
|
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>
|
wspr_mcp-0.1.0/PKG-INFO
ADDED
|
@@ -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
|
wspr_mcp-0.1.0/README.md
ADDED
|
@@ -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()
|