arcade-mcp/libs/tests/arcade_mcp_server/test_settings.py
jottakka 9c47f73602
[TOO-518] Enforce semver for MCPApp Versioning (#793)
Here's the PR summary:

---

## Enforce semver validation for `MCPApp` versioning

### Problem

`MCPApp.__init__` accepted any string as `version` with no validation.
Invalid versions like `"1.0.0dev"` or `"latest"` silently propagated to
the Engine, where `compareToolVersions` fell back to lexicographic
`strings.Compare` instead of `semver.Compare` — causing incorrect
ordering (e.g. `1.10.0 < 1.9.0`).

### Solution

Validate and normalize `version` at `MCPApp` instantiation time using
the same acceptance rules as Go's `golang.org/x/mod/semver v0.31.0` (the
exact version used by the Engine).

### Changes

**`arcade_mcp_server/_validation.py`** (new file)
- Shared regex constants: `SEMVER_PATTERN` (semver.org spec),
`SHORT_VERSION_PATTERN`, `MAJOR_ONLY_PATTERN`

**`arcade_mcp_server/mcp_app.py`**
- Added `_validate_version()` mirroring the existing `_validate_name()`
pattern
- Added `version` property + setter (validates on mutation too)
- `__init__` now stores `self._version` via `_validate_version()`

**`arcade_mcp_server/settings.py`**
- Added `@field_validator("version")` on `ServerSettings` — covers the
`MCP_SERVER_VERSION` env var path
- Fixed default from `"0.1.0dev"` → `"0.1.0"` (the old default was
itself invalid)

**`pyproject.toml`** — bumped `arcade-mcp-server` `1.17.4` → `1.17.5`

### Normalization pipeline

All inputs are normalized to canonical `MAJOR.MINOR.PATCH` before
storage:

| Input | Stored as |
|-------|-----------|
| `v1.0.0` | `1.0.0` |
| `1.0` / `v1.0` | `1.0.0` |
| `1` / `v1` | `1.0.0` |

### Verification

Validated against `golang.org/x/mod/semver v0.31.0` (Engine's exact
pinned version) — 40/40 accept/reject cases match. The Engine's own
`store_test.go` uses `"1.0"` and `"1.1"` as `ToolkitVersion` values,
confirming short forms are intentionally supported.

### Breaking change

Any user currently passing a non-semver version string (e.g.
`"1.0.0dev"`, `"latest"`) will get a `ValueError` on upgrade. This is
intentional — those versions were silently causing incorrect tool
ordering in the Engine.

Closes TOO-518

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Introduces stricter version validation/normalization that will raise
errors for previously-accepted non-semver inputs (including via env
vars), which may break existing consumers depending on lax version
strings.
> 
> **Overview**
> **Enforces semver for server versioning** across both `MCPApp` and
`ServerSettings`, rejecting invalid strings and normalizing accepted
inputs (e.g., stripping leading `v`, expanding `1`/`1.2` to
`1.0.0`/`1.2.0`).
> 
> Adds shared `normalize_version` logic in
`arcade_mcp_server/_validation.py`, updates `MCPApp` to validate on init
and via a new `version` property/setter, and adds a Pydantic `version`
validator so `MCP_SERVER_VERSION` is checked. Defaults are updated from
`0.1.0dev` to `0.1.0`, tests are expanded to cover accept/reject cases,
and the package version is bumped to `1.17.5`.
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
2ceabacb25372e67eef9720b901c1ee2b214868f. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Eric Gustin <34000337+EricGustin@users.noreply.github.com>
2026-03-16 16:06:25 -07:00

147 lines
5.6 KiB
Python

"""Tests for MCP Settings."""
import pytest
from pydantic import ValidationError
from arcade_mcp_server.settings import MCPSettings, ServerSettings
class TestServerSettings:
"""Test ServerSettings class."""
def test_server_settings_defaults(self):
"""Test ServerSettings default values."""
settings = ServerSettings()
assert settings.name == "ArcadeMCP"
assert settings.version == "0.1.0"
assert settings.title == "ArcadeMCP"
assert settings.instructions is not None
assert "available tools" in settings.instructions.lower()
def test_server_settings_custom_values(self):
"""Test ServerSettings with custom values."""
settings = ServerSettings(
name="CustomServer",
version="2.0.0",
title="Custom Title",
instructions="Custom instructions",
)
assert settings.name == "CustomServer"
assert settings.version == "2.0.0"
assert settings.title == "Custom Title"
assert settings.instructions == "Custom instructions"
def test_server_settings_partial_values(self):
"""Test ServerSettings with partial custom values."""
settings = ServerSettings(
name="PartialServer",
version="1.5.0",
)
assert settings.name == "PartialServer"
assert settings.version == "1.5.0"
assert settings.title == "ArcadeMCP" # Default value
assert settings.instructions is not None # Default value
class TestMCPSettings:
"""Test MCPSettings class."""
def test_mcp_settings_defaults(self):
"""Test MCPSettings default values."""
settings = MCPSettings()
assert settings.server.name == "ArcadeMCP"
assert settings.server.version == "0.1.0"
assert settings.server.title == "ArcadeMCP"
assert settings.server.instructions is not None
def test_mcp_settings_with_custom_server(self):
"""Test MCPSettings with custom ServerSettings."""
server_settings = ServerSettings(
name="TestServer",
version="3.0.0",
title="Test Title",
instructions="Test instructions",
)
settings = MCPSettings(server=server_settings)
assert settings.server.name == "TestServer"
assert settings.server.version == "3.0.0"
assert settings.server.title == "Test Title"
assert settings.server.instructions == "Test instructions"
def test_mcp_settings_from_env(self, monkeypatch):
"""Test MCPSettings.from_env() uses environment variables."""
monkeypatch.setenv("MCP_SERVER_NAME", "EnvServer")
monkeypatch.setenv("MCP_SERVER_VERSION", "4.0.0")
monkeypatch.setenv("MCP_SERVER_TITLE", "Env Title")
monkeypatch.setenv("MCP_SERVER_INSTRUCTIONS", "Env instructions")
settings = MCPSettings.from_env()
assert settings.server.name == "EnvServer"
assert settings.server.version == "4.0.0"
assert settings.server.title == "Env Title"
assert settings.server.instructions == "Env instructions"
class TestServerSettingsTitleDefault:
"""Test that the default title value is 'ArcadeMCP'."""
def test_title_default_value(self):
"""Test that the default title value is 'ArcadeMCP'."""
settings = ServerSettings()
assert settings.title == "ArcadeMCP"
def test_title_field_default(self):
"""Test that the title field default is 'ArcadeMCP'."""
field_info = ServerSettings.model_fields["title"]
assert field_info.default == "ArcadeMCP"
class TestServerSettingsVersionValidation:
"""Tests for ServerSettings version validation (semver enforcement)."""
def test_server_settings_rejects_invalid_version(self) -> None:
"""Test ServerSettings raises ValidationError for invalid version."""
with pytest.raises(ValidationError, match="semver"):
ServerSettings(version="bad")
def test_server_settings_accepts_valid_semver(self) -> None:
"""Test ServerSettings accepts valid semver."""
settings = ServerSettings(version="1.2.3-alpha.1+build.456")
assert settings.version == "1.2.3-alpha.1+build.456"
def test_server_settings_normalizes_short_version(self) -> None:
"""Test ServerSettings normalizes MAJOR.MINOR to MAJOR.MINOR.0."""
settings = ServerSettings(version="1.0")
assert settings.version == "1.0.0"
def test_server_settings_normalizes_v_prefix(self) -> None:
"""Test ServerSettings strips v prefix and normalizes the version."""
settings = ServerSettings(version="v1.0.0")
assert settings.version == "1.0.0"
def test_server_settings_normalizes_v_prefix_short(self) -> None:
"""Test ServerSettings strips v prefix from short versions."""
settings = ServerSettings(version="v1.0")
assert settings.version == "1.0.0"
def test_server_settings_normalizes_major_only(self) -> None:
"""Test ServerSettings normalizes MAJOR to MAJOR.0.0."""
settings = ServerSettings(version="1")
assert settings.version == "1.0.0"
def test_server_settings_normalizes_v_major_only(self) -> None:
"""Test ServerSettings strips v prefix from major-only versions."""
settings = ServerSettings(version="v1")
assert settings.version == "1.0.0"
def test_mcp_settings_env_rejects_invalid_version(self, monkeypatch: pytest.MonkeyPatch) -> None:
"""Test MCP_SERVER_VERSION env var is validated."""
monkeypatch.setenv("MCP_SERVER_VERSION", "not-valid")
with pytest.raises(ValidationError, match="semver"):
MCPSettings.from_env()