solar-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/
@@ -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,173 @@
1
+ Metadata-Version: 2.4
2
+ Name: solar-mcp
3
+ Version: 0.1.0
4
+ Summary: MCP server for solar indices and space weather — SFI, SSN, Kp, DSCOVR, alerts
5
+ Project-URL: Homepage, https://qso-graph.io/
6
+ Project-URL: Repository, https://github.com/qso-graph/solar-mcp
7
+ Project-URL: Issues, https://github.com/qso-graph/solar-mcp/issues
8
+ Author: Greg Beam, KI7MT
9
+ License: GPL-3.0-or-later
10
+ License-File: LICENSE
11
+ Keywords: amateur-radio,dscovr,ham-radio,kp-index,mcp,model-context-protocol,noaa,propagation,sfi,solar,space-weather
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
+ # solar-mcp
27
+
28
+ MCP server for space weather and HF propagation conditions — live solar flux, Kp index, DSCOVR solar wind, X-ray flux, alerts, 27-day forecast, and band-by-band outlook through any MCP-compatible AI assistant.
29
+
30
+ Part of the [qso-graph](https://qso-graph.io/) project. **No authentication required** — all data from [NOAA SWPC](https://www.swpc.noaa.gov/) public endpoints.
31
+
32
+ ## Install
33
+
34
+ ```bash
35
+ pip install solar-mcp
36
+ ```
37
+
38
+ ## Tools
39
+
40
+ | Tool | Description |
41
+ |------|-------------|
42
+ | `solar_conditions` | Current SFI, Kp, and NOAA R/S/G space weather scales |
43
+ | `solar_forecast` | 27-day SFI and Kp forecast from NOAA |
44
+ | `solar_alerts` | Active space weather alerts and warnings |
45
+ | `solar_wind` | Real-time DSCOVR L1 solar wind (Bz, speed, density) |
46
+ | `solar_xray` | GOES X-ray flux and solar flare classification |
47
+ | `solar_band_outlook` | HF band-by-band propagation assessment (160m-6m) |
48
+
49
+ ## Quick Start
50
+
51
+ No credentials needed — just install and configure your MCP client.
52
+
53
+ ### Configure your MCP client
54
+
55
+ #### Claude Desktop
56
+
57
+ Add to `claude_desktop_config.json` (`~/Library/Application Support/Claude/` on macOS, `%APPDATA%\Claude\` on Windows):
58
+
59
+ ```json
60
+ {
61
+ "mcpServers": {
62
+ "solar": {
63
+ "command": "solar-mcp"
64
+ }
65
+ }
66
+ }
67
+ ```
68
+
69
+ #### Claude Code
70
+
71
+ Add to `.claude/settings.json`:
72
+
73
+ ```json
74
+ {
75
+ "mcpServers": {
76
+ "solar": {
77
+ "command": "solar-mcp"
78
+ }
79
+ }
80
+ }
81
+ ```
82
+
83
+ #### ChatGPT Desktop
84
+
85
+ ```json
86
+ {
87
+ "mcpServers": {
88
+ "solar": {
89
+ "command": "solar-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
+ "solar": {
103
+ "command": "solar-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
+ "solar": {
117
+ "command": "solar-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
+ "solar": {
131
+ "command": "solar-mcp"
132
+ }
133
+ }
134
+ }
135
+ ```
136
+
137
+ ### Ask questions
138
+
139
+ > "What are the current solar conditions?"
140
+
141
+ > "Is the solar wind causing any geomagnetic disturbance?"
142
+
143
+ > "What's the 27-day solar forecast look like?"
144
+
145
+ > "Are any bands open right now on HF?"
146
+
147
+ > "Are there any active space weather alerts?"
148
+
149
+ > "What class solar flare is happening?"
150
+
151
+ ## Testing Without Network
152
+
153
+ ```bash
154
+ SOLAR_MCP_MOCK=1 solar-mcp
155
+ ```
156
+
157
+ ## MCP Inspector
158
+
159
+ ```bash
160
+ solar-mcp --transport streamable-http --port 8008
161
+ ```
162
+
163
+ ## Development
164
+
165
+ ```bash
166
+ git clone https://github.com/qso-graph/solar-mcp.git
167
+ cd solar-mcp
168
+ pip install -e .
169
+ ```
170
+
171
+ ## License
172
+
173
+ GPL-3.0-or-later
@@ -0,0 +1,148 @@
1
+ # solar-mcp
2
+
3
+ MCP server for space weather and HF propagation conditions — live solar flux, Kp index, DSCOVR solar wind, X-ray flux, alerts, 27-day forecast, and band-by-band outlook through any MCP-compatible AI assistant.
4
+
5
+ Part of the [qso-graph](https://qso-graph.io/) project. **No authentication required** — all data from [NOAA SWPC](https://www.swpc.noaa.gov/) public endpoints.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pip install solar-mcp
11
+ ```
12
+
13
+ ## Tools
14
+
15
+ | Tool | Description |
16
+ |------|-------------|
17
+ | `solar_conditions` | Current SFI, Kp, and NOAA R/S/G space weather scales |
18
+ | `solar_forecast` | 27-day SFI and Kp forecast from NOAA |
19
+ | `solar_alerts` | Active space weather alerts and warnings |
20
+ | `solar_wind` | Real-time DSCOVR L1 solar wind (Bz, speed, density) |
21
+ | `solar_xray` | GOES X-ray flux and solar flare classification |
22
+ | `solar_band_outlook` | HF band-by-band propagation assessment (160m-6m) |
23
+
24
+ ## Quick Start
25
+
26
+ No credentials needed — just install and configure your MCP client.
27
+
28
+ ### Configure your MCP client
29
+
30
+ #### Claude Desktop
31
+
32
+ Add to `claude_desktop_config.json` (`~/Library/Application Support/Claude/` on macOS, `%APPDATA%\Claude\` on Windows):
33
+
34
+ ```json
35
+ {
36
+ "mcpServers": {
37
+ "solar": {
38
+ "command": "solar-mcp"
39
+ }
40
+ }
41
+ }
42
+ ```
43
+
44
+ #### Claude Code
45
+
46
+ Add to `.claude/settings.json`:
47
+
48
+ ```json
49
+ {
50
+ "mcpServers": {
51
+ "solar": {
52
+ "command": "solar-mcp"
53
+ }
54
+ }
55
+ }
56
+ ```
57
+
58
+ #### ChatGPT Desktop
59
+
60
+ ```json
61
+ {
62
+ "mcpServers": {
63
+ "solar": {
64
+ "command": "solar-mcp"
65
+ }
66
+ }
67
+ }
68
+ ```
69
+
70
+ #### Cursor
71
+
72
+ Add to `.cursor/mcp.json` (project-level) or `~/.cursor/mcp.json` (global):
73
+
74
+ ```json
75
+ {
76
+ "mcpServers": {
77
+ "solar": {
78
+ "command": "solar-mcp"
79
+ }
80
+ }
81
+ }
82
+ ```
83
+
84
+ #### VS Code / GitHub Copilot
85
+
86
+ Add to `.vscode/mcp.json` in your workspace:
87
+
88
+ ```json
89
+ {
90
+ "servers": {
91
+ "solar": {
92
+ "command": "solar-mcp"
93
+ }
94
+ }
95
+ }
96
+ ```
97
+
98
+ #### Gemini CLI
99
+
100
+ Add to `~/.gemini/settings.json` (global) or `.gemini/settings.json` (project):
101
+
102
+ ```json
103
+ {
104
+ "mcpServers": {
105
+ "solar": {
106
+ "command": "solar-mcp"
107
+ }
108
+ }
109
+ }
110
+ ```
111
+
112
+ ### Ask questions
113
+
114
+ > "What are the current solar conditions?"
115
+
116
+ > "Is the solar wind causing any geomagnetic disturbance?"
117
+
118
+ > "What's the 27-day solar forecast look like?"
119
+
120
+ > "Are any bands open right now on HF?"
121
+
122
+ > "Are there any active space weather alerts?"
123
+
124
+ > "What class solar flare is happening?"
125
+
126
+ ## Testing Without Network
127
+
128
+ ```bash
129
+ SOLAR_MCP_MOCK=1 solar-mcp
130
+ ```
131
+
132
+ ## MCP Inspector
133
+
134
+ ```bash
135
+ solar-mcp --transport streamable-http --port 8008
136
+ ```
137
+
138
+ ## Development
139
+
140
+ ```bash
141
+ git clone https://github.com/qso-graph/solar-mcp.git
142
+ cd solar-mcp
143
+ pip install -e .
144
+ ```
145
+
146
+ ## License
147
+
148
+ GPL-3.0-or-later
@@ -0,0 +1,42 @@
1
+ [project]
2
+ name = "solar-mcp"
3
+ version = "0.1.0"
4
+ description = "MCP server for solar indices and space weather — SFI, SSN, Kp, DSCOVR, alerts"
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", "solar", "space-weather", "sfi", "kp-index",
14
+ "mcp", "model-context-protocol", "noaa", "dscovr", "propagation",
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/solar-mcp"
32
+ Issues = "https://github.com/qso-graph/solar-mcp/issues"
33
+
34
+ [project.scripts]
35
+ solar-mcp = "solar_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/solar_mcp"]
@@ -0,0 +1,10 @@
1
+ """MCP server for solar indices and space weather — SFI, SSN, Kp, DSCOVR, alerts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ try:
6
+ from importlib.metadata import version
7
+
8
+ __version__ = version("solar-mcp")
9
+ except Exception:
10
+ __version__ = "0.0.0-dev"
@@ -0,0 +1,469 @@
1
+ """NOAA Space Weather Prediction Center client — all public JSON endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import threading
8
+ import time
9
+ import urllib.request
10
+ from typing import Any
11
+
12
+ from . import __version__
13
+
14
+ _SWPC = "https://services.swpc.noaa.gov"
15
+
16
+ # Cache TTLs
17
+ _CONDITIONS_TTL = 900.0 # 15 minutes (SFI/Kp update slowly)
18
+ _FORECAST_TTL = 3600.0 # 1 hour
19
+ _ALERTS_TTL = 300.0 # 5 minutes
20
+ _WIND_TTL = 300.0 # 5 minutes (near-real-time DSCOVR)
21
+ _XRAY_TTL = 300.0 # 5 minutes
22
+
23
+ # Rate limiting
24
+ _MIN_DELAY = 0.2
25
+
26
+
27
+ def _is_mock() -> bool:
28
+ return os.getenv("SOLAR_MCP_MOCK") == "1"
29
+
30
+
31
+ # ---------------------------------------------------------------------------
32
+ # Mock data
33
+ # ---------------------------------------------------------------------------
34
+
35
+ _MOCK_SFI = {"timeStamp": "2026-03-04 20:00:00.000", "flux": "175", "area": ""}
36
+ _MOCK_KP = [
37
+ ["time_tag", "Kp", "Kp_fraction", "a_running", "station_count"],
38
+ ["2026-03-04 21:00:00.000", "3", "2.67", "15", "8"],
39
+ ]
40
+ _MOCK_SCALES = {
41
+ "0": {
42
+ "R": {"Scale": "R0", "Text": "none"},
43
+ "S": {"Scale": "S0", "Text": "none"},
44
+ "G": {"Scale": "G0", "Text": "none"},
45
+ },
46
+ "-1": {
47
+ "R": {"Scale": "R1", "Text": "minor"},
48
+ "S": {"Scale": "S0", "Text": "none"},
49
+ "G": {"Scale": "G1", "Text": "minor"},
50
+ },
51
+ }
52
+ _MOCK_WIND_MAG = [
53
+ ["time_tag", "bx_gsm", "by_gsm", "bz_gsm", "bt"],
54
+ ["2026-03-04 21:00:00.000", "-2.1", "1.5", "-3.2", "4.1"],
55
+ ]
56
+ _MOCK_WIND_PLASMA = [
57
+ ["time_tag", "density", "speed", "temperature"],
58
+ ["2026-03-04 21:00:00.000", "5.2", "425.0", "85000"],
59
+ ]
60
+ _MOCK_XRAY = [
61
+ {"time_tag": "2026-03-04T21:00:00Z", "satellite": 16, "current_class": "B5.2",
62
+ "current_ratio": 5.2e-7, "current_int_xrlong": 5.2e-7},
63
+ ]
64
+ _MOCK_ALERTS = [
65
+ {
66
+ "product_id": "ALTK04",
67
+ "issue_datetime": "2026-03-04T18:00:00Z",
68
+ "message": "ALERT: Geomagnetic K-index of 4\nThreshold Reached: 2026 Mar 04 1800 UTC\nSynoptic Period: 1500-1800 UTC",
69
+ },
70
+ ]
71
+ _MOCK_FORECAST = [
72
+ ["2026 Mar 05", "172", "3"],
73
+ ["2026 Mar 06", "170", "2"],
74
+ ["2026 Mar 07", "168", "2"],
75
+ ]
76
+
77
+
78
+ class SolarClient:
79
+ """NOAA SWPC client with rate limiting and caching."""
80
+
81
+ def __init__(self) -> None:
82
+ self._lock = threading.Lock()
83
+ self._last_request: float = 0.0
84
+ self._cache: dict[str, tuple[float, Any]] = {}
85
+
86
+ # ------------------------------------------------------------------
87
+ # Cache + HTTP
88
+ # ------------------------------------------------------------------
89
+
90
+ def _cache_get(self, key: str) -> Any | None:
91
+ entry = self._cache.get(key)
92
+ if entry is None:
93
+ return None
94
+ expires, value = entry
95
+ if time.monotonic() > expires:
96
+ del self._cache[key]
97
+ return None
98
+ return value
99
+
100
+ def _cache_set(self, key: str, value: Any, ttl: float) -> None:
101
+ self._cache[key] = (time.monotonic() + ttl, value)
102
+
103
+ def _rate_limit(self) -> None:
104
+ with self._lock:
105
+ now = time.monotonic()
106
+ elapsed = now - self._last_request
107
+ if elapsed < _MIN_DELAY:
108
+ time.sleep(_MIN_DELAY - elapsed)
109
+ self._last_request = time.monotonic()
110
+
111
+ def _get_json(self, url: str) -> Any:
112
+ self._rate_limit()
113
+ req = urllib.request.Request(url, method="GET")
114
+ req.add_header("User-Agent", f"solar-mcp/{__version__}")
115
+ with urllib.request.urlopen(req, timeout=15) as resp:
116
+ body = resp.read().decode("utf-8", errors="replace")
117
+ if not body or body.strip() == "":
118
+ return None
119
+ return json.loads(body)
120
+
121
+ # ------------------------------------------------------------------
122
+ # Tools
123
+ # ------------------------------------------------------------------
124
+
125
+ def conditions(self) -> dict[str, Any]:
126
+ """Current solar conditions: SFI, Kp, NOAA scales."""
127
+ key = "conditions"
128
+ cached = self._cache_get(key)
129
+ if cached is not None:
130
+ return cached
131
+
132
+ if _is_mock():
133
+ sfi_data = _MOCK_SFI
134
+ kp_data = _MOCK_KP
135
+ scales_data = _MOCK_SCALES
136
+ else:
137
+ sfi_data = self._get_json(f"{_SWPC}/products/summary/10cm-flux.json") or {}
138
+ kp_data = self._get_json(f"{_SWPC}/products/noaa-planetary-k-index.json") or []
139
+ scales_data = self._get_json(f"{_SWPC}/products/noaa-scales.json") or {}
140
+
141
+ # Parse SFI
142
+ sfi = None
143
+ sfi_time = None
144
+ if isinstance(sfi_data, dict):
145
+ try:
146
+ sfi = int(sfi_data.get("flux", "0"))
147
+ except (ValueError, TypeError):
148
+ pass
149
+ sfi_time = sfi_data.get("timeStamp")
150
+
151
+ # Parse latest Kp
152
+ kp = None
153
+ kp_time = None
154
+ if isinstance(kp_data, list) and len(kp_data) > 1:
155
+ latest = kp_data[-1]
156
+ if isinstance(latest, list) and len(latest) >= 2:
157
+ try:
158
+ kp = float(latest[1])
159
+ except (ValueError, TypeError):
160
+ pass
161
+ kp_time = latest[0]
162
+
163
+ # Parse NOAA scales (current = "0", 24hr max = "-1")
164
+ r_scale = s_scale = g_scale = "unknown"
165
+ if isinstance(scales_data, dict):
166
+ current = scales_data.get("0", {})
167
+ r_scale = current.get("R", {}).get("Scale", "unknown")
168
+ s_scale = current.get("S", {}).get("Scale", "unknown")
169
+ g_scale = current.get("G", {}).get("Scale", "unknown")
170
+
171
+ result: dict[str, Any] = {
172
+ "sfi": sfi,
173
+ "sfi_timestamp": sfi_time,
174
+ "kp": kp,
175
+ "kp_timestamp": kp_time,
176
+ "noaa_r_scale": r_scale,
177
+ "noaa_s_scale": s_scale,
178
+ "noaa_g_scale": g_scale,
179
+ }
180
+
181
+ # Band outlook
182
+ if sfi is not None and kp is not None:
183
+ result["band_outlook"] = self._band_outlook(sfi, kp)
184
+
185
+ self._cache_set(key, result, _CONDITIONS_TTL)
186
+ return result
187
+
188
+ def forecast(self) -> dict[str, Any]:
189
+ """3-day SFI/Kp forecast from NOAA 27-day outlook."""
190
+ key = "forecast"
191
+ cached = self._cache_get(key)
192
+ if cached is not None:
193
+ return cached
194
+
195
+ if _is_mock():
196
+ rows = _MOCK_FORECAST
197
+ else:
198
+ data = self._get_json(f"{_SWPC}/products/27-day-outlook.json") or []
199
+ # Format: [date, sfi, kp] rows, first row may be header
200
+ rows = []
201
+ for row in data:
202
+ if isinstance(row, list) and len(row) >= 3:
203
+ try:
204
+ int(row[1]) # test if SFI is numeric
205
+ rows.append(row)
206
+ except (ValueError, TypeError):
207
+ continue
208
+
209
+ days = []
210
+ for row in rows[:27]:
211
+ try:
212
+ days.append({
213
+ "date": row[0],
214
+ "predicted_sfi": int(row[1]),
215
+ "predicted_kp": int(row[2]),
216
+ })
217
+ except (ValueError, TypeError, IndexError):
218
+ continue
219
+
220
+ result = {"total_days": len(days), "forecast": days}
221
+ self._cache_set(key, result, _FORECAST_TTL)
222
+ return result
223
+
224
+ def alerts(self) -> dict[str, Any]:
225
+ """Active SWPC alerts and warnings."""
226
+ key = "alerts"
227
+ cached = self._cache_get(key)
228
+ if cached is not None:
229
+ return cached
230
+
231
+ if _is_mock():
232
+ data = list(_MOCK_ALERTS)
233
+ else:
234
+ data = self._get_json(f"{_SWPC}/products/alerts.json") or []
235
+
236
+ alerts = []
237
+ for item in data:
238
+ if isinstance(item, dict):
239
+ alerts.append({
240
+ "product_id": item.get("product_id", ""),
241
+ "issue_time": item.get("issue_datetime", ""),
242
+ "message": item.get("message", ""),
243
+ })
244
+
245
+ result = {"total": len(alerts), "alerts": alerts}
246
+ self._cache_set(key, result, _ALERTS_TTL)
247
+ return result
248
+
249
+ def solar_wind(self) -> dict[str, Any]:
250
+ """Real-time DSCOVR L1 solar wind data."""
251
+ key = "wind"
252
+ cached = self._cache_get(key)
253
+ if cached is not None:
254
+ return cached
255
+
256
+ if _is_mock():
257
+ mag_data = _MOCK_WIND_MAG
258
+ plasma_data = _MOCK_WIND_PLASMA
259
+ else:
260
+ mag_data = self._get_json(f"{_SWPC}/products/solar-wind/mag-5-minute.json") or []
261
+ plasma_data = self._get_json(f"{_SWPC}/products/solar-wind/plasma-5-minute.json") or []
262
+
263
+ # Latest magnetic field
264
+ bz = bt = None
265
+ mag_time = None
266
+ if isinstance(mag_data, list) and len(mag_data) > 1:
267
+ latest = mag_data[-1]
268
+ if isinstance(latest, list) and len(latest) >= 4:
269
+ mag_time = latest[0]
270
+ try:
271
+ bz = float(latest[3])
272
+ except (ValueError, TypeError):
273
+ pass
274
+ try:
275
+ bt = float(latest[4]) if len(latest) > 4 else None
276
+ except (ValueError, TypeError):
277
+ pass
278
+
279
+ # Latest plasma
280
+ speed = density = None
281
+ plasma_time = None
282
+ if isinstance(plasma_data, list) and len(plasma_data) > 1:
283
+ latest = plasma_data[-1]
284
+ if isinstance(latest, list) and len(latest) >= 3:
285
+ plasma_time = latest[0]
286
+ try:
287
+ density = float(latest[1])
288
+ except (ValueError, TypeError):
289
+ pass
290
+ try:
291
+ speed = float(latest[2])
292
+ except (ValueError, TypeError):
293
+ pass
294
+
295
+ result: dict[str, Any] = {
296
+ "bz_gsm_nt": bz,
297
+ "bt_nt": bt,
298
+ "mag_timestamp": mag_time,
299
+ "speed_km_s": speed,
300
+ "density_p_cm3": density,
301
+ "plasma_timestamp": plasma_time,
302
+ }
303
+
304
+ # Geomagnetic storm assessment
305
+ if bz is not None:
306
+ if bz < -10:
307
+ result["assessment"] = "Strongly southward Bz — major storm likely"
308
+ elif bz < -5:
309
+ result["assessment"] = "Southward Bz — moderate storm possible"
310
+ elif bz < 0:
311
+ result["assessment"] = "Mildly southward Bz — minor disturbance possible"
312
+ else:
313
+ result["assessment"] = "Northward or neutral Bz — quiet conditions"
314
+
315
+ self._cache_set(key, result, _WIND_TTL)
316
+ return result
317
+
318
+ def xray(self) -> dict[str, Any]:
319
+ """GOES X-ray flux and solar flare status."""
320
+ key = "xray"
321
+ cached = self._cache_get(key)
322
+ if cached is not None:
323
+ return cached
324
+
325
+ if _is_mock():
326
+ data = _MOCK_XRAY
327
+ else:
328
+ data = self._get_json(f"{_SWPC}/json/goes/primary/xrays-6-hour.json") or []
329
+
330
+ # Latest reading
331
+ flare_class = "unknown"
332
+ xray_time = None
333
+ flux = None
334
+ if isinstance(data, list) and len(data) > 0:
335
+ latest = data[-1] if isinstance(data[-1], dict) else {}
336
+ flare_class = latest.get("current_class", "unknown")
337
+ xray_time = latest.get("time_tag")
338
+ flux = latest.get("current_ratio") or latest.get("current_int_xrlong")
339
+
340
+ result: dict[str, Any] = {
341
+ "flare_class": flare_class,
342
+ "flux_w_m2": flux,
343
+ "timestamp": xray_time,
344
+ }
345
+
346
+ # Classify
347
+ if isinstance(flare_class, str) and len(flare_class) > 0:
348
+ c = flare_class[0].upper()
349
+ if c == "X":
350
+ result["level"] = "Extreme — HF blackout likely"
351
+ elif c == "M":
352
+ result["level"] = "Strong — HF degradation possible"
353
+ elif c == "C":
354
+ result["level"] = "Moderate — minor HF impact"
355
+ elif c == "B":
356
+ result["level"] = "Low — no significant impact"
357
+ elif c == "A":
358
+ result["level"] = "Minimal — background levels"
359
+
360
+ self._cache_set(key, result, _XRAY_TTL)
361
+ return result
362
+
363
+ def band_outlook(self) -> dict[str, Any]:
364
+ """HF band condition assessment derived from current indices."""
365
+ key = "band_outlook"
366
+ cached = self._cache_get(key)
367
+ if cached is not None:
368
+ return cached
369
+
370
+ # Need current SFI and Kp
371
+ cond = self.conditions()
372
+ sfi = cond.get("sfi")
373
+ kp = cond.get("kp")
374
+
375
+ if sfi is None or kp is None:
376
+ return {"error": "Unable to fetch current conditions"}
377
+
378
+ result = self._band_outlook(sfi, kp)
379
+ result["sfi"] = sfi
380
+ result["kp"] = kp
381
+ self._cache_set(key, result, _CONDITIONS_TTL)
382
+ return result
383
+
384
+ @staticmethod
385
+ def _band_outlook(sfi: int, kp: float) -> dict[str, Any]:
386
+ """Generate band-by-band outlook from SFI and Kp."""
387
+ storm = kp >= 5
388
+ disturbed = kp >= 4
389
+ active = kp >= 3
390
+
391
+ bands = {}
392
+
393
+ # 160m — always NVIS night, storms help (absorption kills skip)
394
+ if storm:
395
+ bands["160m"] = "Poor — storm absorption"
396
+ elif active:
397
+ bands["160m"] = "Fair — some noise"
398
+ else:
399
+ bands["160m"] = "Good — quiet night conditions"
400
+
401
+ # 80m
402
+ if storm:
403
+ bands["80m"] = "Poor — D-layer absorption and noise"
404
+ elif disturbed:
405
+ bands["80m"] = "Fair — elevated absorption"
406
+ else:
407
+ bands["80m"] = "Good — reliable night band"
408
+
409
+ # 40m
410
+ if storm:
411
+ bands["40m"] = "Fair — reduced DX"
412
+ else:
413
+ bands["40m"] = "Good — workhorse band, day and night"
414
+
415
+ # 30m
416
+ bands["30m"] = "Good — transition band, usually open"
417
+
418
+ # 20m
419
+ if sfi >= 150:
420
+ bands["20m"] = "Excellent — strong F-layer, long openings"
421
+ elif sfi >= 100:
422
+ bands["20m"] = "Good — reliable daytime DX"
423
+ else:
424
+ bands["20m"] = "Fair — shorter openings at low solar flux"
425
+
426
+ # 17m
427
+ if sfi >= 120:
428
+ bands["17m"] = "Good — DX openings likely"
429
+ elif sfi >= 90:
430
+ bands["17m"] = "Fair — marginal openings"
431
+ else:
432
+ bands["17m"] = "Poor — insufficient ionization"
433
+
434
+ # 15m
435
+ if sfi >= 140:
436
+ bands["15m"] = "Excellent — wide open DX"
437
+ elif sfi >= 100:
438
+ bands["15m"] = "Good — daytime openings"
439
+ elif sfi >= 80:
440
+ bands["15m"] = "Fair — sporadic openings"
441
+ else:
442
+ bands["15m"] = "Poor — band likely closed"
443
+
444
+ # 12m
445
+ if sfi >= 150:
446
+ bands["12m"] = "Good — DX openings around midday"
447
+ elif sfi >= 120:
448
+ bands["12m"] = "Fair — brief openings possible"
449
+ else:
450
+ bands["12m"] = "Poor — band likely closed"
451
+
452
+ # 10m
453
+ if sfi >= 160:
454
+ bands["10m"] = "Excellent — worldwide DX"
455
+ elif sfi >= 130:
456
+ bands["10m"] = "Good — daytime DX openings"
457
+ elif sfi >= 100:
458
+ bands["10m"] = "Fair — short openings or sporadic-E"
459
+ else:
460
+ bands["10m"] = "Poor — closed except sporadic-E"
461
+
462
+ # 6m
463
+ bands["6m"] = "Sporadic — watch for Es openings"
464
+
465
+ if storm:
466
+ for b in ["20m", "17m", "15m", "12m", "10m"]:
467
+ bands[b] = bands[b].split(" — ")[0] + " — degraded by geomagnetic storm"
468
+
469
+ return {"bands": bands}
@@ -0,0 +1,162 @@
1
+ """solar-mcp: MCP server for space weather and HF propagation conditions."""
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 SolarClient
12
+
13
+ mcp = FastMCP(
14
+ "solar-mcp",
15
+ version=__version__,
16
+ instructions=(
17
+ "MCP server for space weather and HF propagation conditions. "
18
+ "Live solar flux (SFI), Kp index, DSCOVR solar wind, X-ray flux, "
19
+ "space weather alerts, 27-day forecast, and HF band outlook. "
20
+ "All data from NOAA SWPC public endpoints, no authentication required."
21
+ ),
22
+ )
23
+
24
+ _client: SolarClient | None = None
25
+
26
+
27
+ def _get_client() -> SolarClient:
28
+ global _client
29
+ if _client is None:
30
+ _client = SolarClient()
31
+ return _client
32
+
33
+
34
+ # ---------------------------------------------------------------------------
35
+ # Tools
36
+ # ---------------------------------------------------------------------------
37
+
38
+
39
+ @mcp.tool()
40
+ def solar_conditions() -> dict[str, Any]:
41
+ """Get current solar conditions — SFI, Kp, and NOAA space weather scales.
42
+
43
+ Returns the latest 10.7 cm solar flux index (SFI), planetary Kp index,
44
+ and NOAA R/S/G scales (radio blackout, solar radiation, geomagnetic storm).
45
+ Includes an HF band outlook derived from current indices.
46
+
47
+ Returns:
48
+ Current SFI, Kp, NOAA scales, and band-by-band propagation outlook.
49
+ """
50
+ try:
51
+ return _get_client().conditions()
52
+ except Exception as e:
53
+ return {"error": str(e)}
54
+
55
+
56
+ @mcp.tool()
57
+ def solar_forecast() -> dict[str, Any]:
58
+ """Get the NOAA 27-day solar flux and geomagnetic forecast.
59
+
60
+ Shows predicted SFI and Kp values for the next 27 days, useful for
61
+ planning DX operations, contests, and POTA/SOTA activations.
62
+
63
+ Returns:
64
+ Day-by-day forecast with predicted SFI and Kp values.
65
+ """
66
+ try:
67
+ return _get_client().forecast()
68
+ except Exception as e:
69
+ return {"error": str(e)}
70
+
71
+
72
+ @mcp.tool()
73
+ def solar_alerts() -> dict[str, Any]:
74
+ """Get active NOAA space weather alerts and warnings.
75
+
76
+ Shows current solar flare alerts, geomagnetic storm warnings,
77
+ radiation storm alerts, and other SWPC bulletins.
78
+
79
+ Returns:
80
+ List of active alerts with product ID, issue time, and message text.
81
+ """
82
+ try:
83
+ return _get_client().alerts()
84
+ except Exception as e:
85
+ return {"error": str(e)}
86
+
87
+
88
+ @mcp.tool()
89
+ def solar_wind() -> dict[str, Any]:
90
+ """Get real-time DSCOVR L1 solar wind data.
91
+
92
+ Shows interplanetary magnetic field (Bz component), solar wind speed,
93
+ and proton density from the DSCOVR satellite at L1 (~1.5M km sunward).
94
+ Southward Bz (negative) drives geomagnetic storms.
95
+
96
+ Returns:
97
+ Bz (nT), Bt (nT), wind speed (km/s), density (p/cm³),
98
+ and geomagnetic storm assessment.
99
+ """
100
+ try:
101
+ return _get_client().solar_wind()
102
+ except Exception as e:
103
+ return {"error": str(e)}
104
+
105
+
106
+ @mcp.tool()
107
+ def solar_xray() -> dict[str, Any]:
108
+ """Get GOES X-ray flux and solar flare status.
109
+
110
+ Shows the current X-ray classification (A, B, C, M, X) from
111
+ GOES satellite data. M and X class flares can cause HF radio blackouts.
112
+
113
+ Returns:
114
+ Current flare class, X-ray flux, and HF impact assessment.
115
+ """
116
+ try:
117
+ return _get_client().xray()
118
+ except Exception as e:
119
+ return {"error": str(e)}
120
+
121
+
122
+ @mcp.tool()
123
+ def solar_band_outlook() -> dict[str, Any]:
124
+ """Get HF band-by-band propagation outlook based on current conditions.
125
+
126
+ Derives a propagation assessment for each HF band (160m through 6m)
127
+ from the current SFI and Kp values. Useful for deciding which band
128
+ to operate on right now.
129
+
130
+ Returns:
131
+ Per-band condition rating (Poor/Fair/Good/Excellent) with explanation,
132
+ plus current SFI and Kp values.
133
+ """
134
+ try:
135
+ return _get_client().band_outlook()
136
+ except Exception as e:
137
+ return {"error": str(e)}
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Entry point
142
+ # ---------------------------------------------------------------------------
143
+
144
+
145
+ def main() -> None:
146
+ """Run the solar-mcp server."""
147
+ transport = "stdio"
148
+ port = 8008
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()