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.
- solar_mcp-0.1.0/.github/workflows/publish.yml +25 -0
- solar_mcp-0.1.0/.gitignore +6 -0
- solar_mcp-0.1.0/LICENSE +22 -0
- solar_mcp-0.1.0/PKG-INFO +173 -0
- solar_mcp-0.1.0/README.md +148 -0
- solar_mcp-0.1.0/pyproject.toml +42 -0
- solar_mcp-0.1.0/src/solar_mcp/__init__.py +10 -0
- solar_mcp-0.1.0/src/solar_mcp/client.py +469 -0
- solar_mcp-0.1.0/src/solar_mcp/server.py +162 -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
|
solar_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>
|
solar_mcp-0.1.0/PKG-INFO
ADDED
|
@@ -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()
|