Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
name: CI

on:
pull_request:
branches: [main]
push:
branches: [main]

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."
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Changelog

All notable changes to `wspr-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.3.2] — 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 `wspr-live-v1`
for the current wspr.live ClickHouse schema.
- L2 unit tests WSPR-L2-046 through WSPR-L2-050 covering the new tool.
- `.github/workflows/ci.yml` — PR-gating CI workflow (py3.10-3.13 matrix,
unit + security tests, ci-all-green aggregator).

### Changed
- `__init__.py` modernized to mirror the `adif-mcp`/`solar-mcp` pattern
(`Final` types, explicit `PackageNotFoundError` handling).

## [0.3.1] — Previous release
- See git history for changes prior to the changelog being introduced.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ pip install wspr-mcp
| `wspr_grid_activity` | All WSPR activity in/out of a Maidenhead grid square | grid (2 or 4 char), band, hours |
| `wspr_longest_paths` | Longest distance WSPR paths in a time window | band, hours, min_distance, limit |
| `wspr_snr_trend` | Hourly SNR trend for a specific path over time | tx, rx, band, hours |
| `get_version_info` | Service version + upstream spec version (fleet identity attestation) | — |

## What is WSPR?

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[project]
name = "wspr-mcp"
version = "0.3.1"
version = "0.3.2"
description = "MCP server for WSPR beacon data analytics — band openings, path analysis, solar correlation"
readme = "README.md"
license = {text = "GPL-3.0-or-later"}
Expand Down
17 changes: 13 additions & 4 deletions src/wspr_mcp/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,18 @@

from __future__ import annotations

from importlib.metadata import PackageNotFoundError, version
from typing import Final

try:
from importlib.metadata import version
_pkg_version = version("wspr-mcp")
except PackageNotFoundError: # local dev / editable installs without dist metadata
_pkg_version = "0.0.0-dev"

__version__: Final[str] = _pkg_version

__version__ = version("wspr-mcp")
except Exception:
__version__ = "0.0.0-dev"
# Upstream data spec the server is bound to. Pinned to the wspr.live
# ClickHouse schema revision we query — bump this when wspr.live
# publishes a new schema. Reported by the get_version_info tool so
# agents can detect fleet drift without going outside the MCP protocol.
__spec_version__: Final[str] = "wspr-live-v1"
33 changes: 32 additions & 1 deletion src/wspr_mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

from fastmcp import FastMCP

from . import __version__
from . import __spec_version__, __version__
from .client import WSPRClient

mcp = FastMCP(
Expand All @@ -38,6 +38,37 @@ def _get_client() -> WSPRClient:
return _client


# ---------------------------------------------------------------------------
# Tool 0: get_version_info — fleet identity attestation
# ---------------------------------------------------------------------------


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": "wspr-mcp",
"service_version": __version__,
"spec_version": __spec_version__,
}


@mcp.tool()
def get_version_info() -> dict[str, Any]:
"""Get wspr-mcp service version and upstream spec version.

Returns the running PyPI version of wspr-mcp and the wspr.live
ClickHouse schema revision 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 (wspr.live schema).
"""
return _version_info_payload()


# ---------------------------------------------------------------------------
# Tool 1: wspr_spots — "Who's hearing me?" / "What's on the air?"
# ---------------------------------------------------------------------------
Expand Down
45 changes: 45 additions & 0 deletions tests/test_tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -268,3 +268,48 @@ def test_band_filter_value(self):
"""WSPR-L2-045: Band filter with value returns SQL fragment."""
result = WSPRClient._band_filter(14)
assert result == "band = 14"


# ---------------------------------------------------------------------------
# WSPR-L2-046..050: 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):
"""WSPR-L2-046: payload includes service_name = 'wspr-mcp'."""
from wspr_mcp.server import _version_info_payload

assert _version_info_payload()["service_name"] == "wspr-mcp"

def test_returns_service_version(self):
"""WSPR-L2-047: service_version matches package __version__."""
from wspr_mcp import __version__
from wspr_mcp.server import _version_info_payload

assert _version_info_payload()["service_version"] == __version__

def test_returns_spec_version(self):
"""WSPR-L2-048: spec_version pins the wspr.live ClickHouse schema."""
from wspr_mcp.server import _version_info_payload

assert _version_info_payload()["spec_version"] == "wspr-live-v1"

def test_payload_keys_are_required_set(self):
"""WSPR-L2-049: payload has exactly the required keys (no extras yet)."""
from wspr_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):
"""WSPR-L2-050: all returned values are strings (JSON-safe envelope)."""
from wspr_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"
Loading