diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..f745f98 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +# Cancel superseded runs on the same ref (push or PR) so we don't waste +# minutes when commits land in rapid succession. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: pytest (${{ matrix.python-version }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python-version: ["3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + + - name: Install package + test dependencies + run: | + python -m pip install --upgrade pip + pip install -e . + pip install pytest pytest-asyncio + + - name: Run unit tests (L2) + run: python -m pytest tests/test_tools.py -v + + - name: Run security tests + run: python -m pytest tests/test_security.py -v + + ci-all-green: + name: ci-all-green + needs: [test] + runs-on: ubuntu-latest + if: always() + steps: + - name: Verify required jobs all succeeded + run: | + if [[ "${{ needs.test.result }}" != "success" ]]; then + echo "FAIL: test matrix did not all succeed (result=${{ needs.test.result }})" + exit 1 + fi + echo "All required CI jobs passed." diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b8feac8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +All notable changes to `solar-mcp` are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [0.2.1] — 2026-05-15 + +### Added +- New tool `get_version_info` — returns `{service_name, service_version, spec_version}` + for fleet identity attestation. Lets agents detect version drift across MCP + deployments without going outside the protocol. Tracks + [IONIS-AI/ionis-devel#49](https://github.com/IONIS-AI/ionis-devel/issues/49) + (fleet rollout). +- `__spec_version__` constant in package `__init__.py`, pinned to `noaa-swpc-v1` + for the current NOAA SWPC endpoint set. +- L2 unit tests SOLAR-L2-041 through SOLAR-L2-045 covering the new tool. + +### Changed +- `__init__.py` modernized to mirror the `adif-mcp` pattern (`Final` types, + explicit `PackageNotFoundError` handling). + +## [0.2.0] — 2026-05-14 + +- Standardized `pyproject.toml` metadata (email, URLs, classifiers). +- Added L2 unit tests for all 6 tools and helper functions. +- Added L3 live integration tests against NOAA SWPC endpoints. diff --git a/README.md b/README.md index 75e0cf2..06110c7 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ pip install solar-mcp | `solar_wind` | Real-time DSCOVR L1 solar wind (Bz, speed, density) | | `solar_xray` | GOES X-ray flux and solar flare classification | | `solar_band_outlook` | HF band-by-band propagation assessment (160m-6m) | +| `get_version_info` | Service version + upstream spec version (fleet identity attestation) | ## Quick Start diff --git a/pyproject.toml b/pyproject.toml index fa73b79..6b9de20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "solar-mcp" -version = "0.2.0" +version = "0.2.1" description = "MCP server for solar indices and space weather — SFI, SSN, Kp, DSCOVR, alerts" readme = "README.md" license = {text = "GPL-3.0-or-later"} diff --git a/src/solar_mcp/__init__.py b/src/solar_mcp/__init__.py index 00334f9..e2254d8 100644 --- a/src/solar_mcp/__init__.py +++ b/src/solar_mcp/__init__.py @@ -2,9 +2,19 @@ from __future__ import annotations +from importlib.metadata import PackageNotFoundError, version +from typing import Final + try: - from importlib.metadata import version + _pkg_version = version("solar-mcp") +except PackageNotFoundError: # local dev / editable installs without dist metadata + _pkg_version = "0.0.0-dev" + +__version__: Final[str] = _pkg_version - __version__ = version("solar-mcp") -except Exception: - __version__ = "0.0.0-dev" +# Upstream data spec the server is bound to. Pinned via the NOAA SWPC public +# endpoint set we consume — bump this when SWPC publishes a new endpoint +# contract (different JSON schema, new feed family, etc.). Reported by the +# get_version_info tool so agents can detect fleet drift without going +# outside the MCP protocol. +__spec_version__: Final[str] = "noaa-swpc-v1" diff --git a/src/solar_mcp/server.py b/src/solar_mcp/server.py index 96a3730..1389095 100644 --- a/src/solar_mcp/server.py +++ b/src/solar_mcp/server.py @@ -7,7 +7,7 @@ from fastmcp import FastMCP -from . import __version__ +from . import __spec_version__, __version__ from .client import SolarClient mcp = FastMCP( @@ -36,6 +36,33 @@ def _get_client() -> SolarClient: # --------------------------------------------------------------------------- +def _version_info_payload() -> dict[str, Any]: + """Build the version info envelope. Pulled into a helper so tests can + call it directly without going through the FastMCP wrapper.""" + return { + "service_name": "solar-mcp", + "service_version": __version__, + "spec_version": __spec_version__, + } + + +@mcp.tool() +def get_version_info() -> dict[str, Any]: + """Get solar-mcp service version and upstream spec version. + + Returns the running PyPI version of solar-mcp and the NOAA SWPC + endpoint set revision currently in use. Use this to confirm fleet + alignment across MCP deployments — agents can compare service_version + and spec_version across servers to detect drift without going outside + the MCP protocol. + + Returns: + service_name, service_version (PyPI), and spec_version (NOAA SWPC + endpoint set). + """ + return _version_info_payload() + + @mcp.tool() def solar_conditions() -> dict[str, Any]: """Get current solar conditions — SFI, Kp, and NOAA space weather scales. diff --git a/tests/test_tools.py b/tests/test_tools.py index 499263e..da0da0b 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -298,3 +298,48 @@ def test_classify_xray_extreme(self): # Quiet sun result = SolarClient._classify_xray(1e-9) assert result.startswith("A") + + +# --------------------------------------------------------------------------- +# SOLAR-L2-041..045: get_version_info — fleet identity attestation +# --------------------------------------------------------------------------- + + +class TestGetVersionInfo: + """Tracks IONIS-AI/ionis-devel#49 — fleet get_version_info convention.""" + + def test_returns_service_name(self): + """SOLAR-L2-041: payload includes service_name = 'solar-mcp'.""" + from solar_mcp.server import _version_info_payload + + assert _version_info_payload()["service_name"] == "solar-mcp" + + def test_returns_service_version(self): + """SOLAR-L2-042: service_version matches package __version__.""" + from solar_mcp import __version__ + from solar_mcp.server import _version_info_payload + + assert _version_info_payload()["service_version"] == __version__ + + def test_returns_spec_version(self): + """SOLAR-L2-043: spec_version pins the NOAA SWPC endpoint set.""" + from solar_mcp.server import _version_info_payload + + assert _version_info_payload()["spec_version"] == "noaa-swpc-v1" + + def test_payload_keys_are_required_set(self): + """SOLAR-L2-044: payload has exactly the required keys (no extras yet).""" + from solar_mcp.server import _version_info_payload + + result = _version_info_payload() + required = {"service_name", "service_version", "spec_version"} + assert required.issubset(set(result.keys())) + + def test_all_values_are_strings(self): + """SOLAR-L2-045: all returned values are strings (JSON-safe envelope).""" + from solar_mcp.server import _version_info_payload + + result = _version_info_payload() + for k in ("service_name", "service_version", "spec_version"): + assert isinstance(result[k], str), f"{k} should be str, got {type(result[k])}" + assert result[k], f"{k} should be non-empty"