Files
intervalsicu-mcp/tests/test_tool_auth.py
Chris Farhood 7e3e1a772c ci: exact-version changelog match; test: auth-gate matrix syncs with registry
The release-notes awk start pattern was an unterminated prefix match ("## v0.3.0"
also re-armed on "## v0.3.01"); escape dots and anchor on the trailing space.

test_tool_auth's hand-counted matrix had drifted (update_wellness from 0.2.0 and
all 11 new tools were missing). Add all 12 and replace the count guard with a
comparison against the live mcp tool registry so drift fails loudly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NGzHtDvJur9U7ysgRKRUTN
2026-07-20 16:27:52 -04:00

88 lines
3.1 KiB
Python

"""
Every tool must refuse to act (and surface a helpful message) when the caller
has no usable credentials — a disabled/unapproved user, or one who hasn't set up
their Intervals.icu key. This guards the admin-approval gate: there is no tool
parameter a caller can pass to bypass it.
"""
import asyncio
import pytest
from intervals_mcp_server import credentials
from intervals_mcp_server.credentials import CredentialError
from intervals_mcp_server.tools import (
activities,
athlete,
custom_items,
events,
gear,
power_curves,
wellness,
workouts,
)
# (tool callable, minimal required positional args)
TOOL_CALLS: list[tuple] = [
(activities.get_activities, ()),
(activities.get_activity_details, ("1",)),
(activities.get_activity_intervals, ("1",)),
(activities.get_activity_streams, ("1",)),
(activities.get_activity_messages, ("1",)),
(activities.add_activity_message, ("1", "hi")),
(activities.search_activities, ("ride",)),
(activities.get_activity_best_efforts, ("1",)),
(activities.get_activity_interval_stats, ("1", 0, 100)),
(events.get_events, ()),
(events.get_event_by_id, ("e1",)),
(events.delete_event, ("e1",)),
(events.delete_events_by_date_range, ("2026-07-01", "2026-07-31")),
(events.add_or_update_event, ("Ride", "Name")),
(events.add_or_update_note, ("Name", "desc")),
(wellness.get_wellness_data, ()),
(wellness.update_wellness, ()),
(wellness.update_wellness_bulk, ([],)),
(wellness.get_training_readiness, ()),
(athlete.get_athlete_profile, ()),
(athlete.get_sport_settings, ()),
(athlete.get_athlete_summary, ()),
(athlete.update_sport_settings, (1,)),
(workouts.get_workouts, ()),
(workouts.get_workout, (1,)),
(power_curves.get_athlete_power_curves, ()),
(gear.get_gear_list, ()),
(custom_items.get_custom_items, ()),
(custom_items.get_custom_item_by_id, (1,)),
(custom_items.create_custom_item, ("N", "TYPE")),
(custom_items.update_custom_item, (1,)),
(custom_items.delete_custom_item, (1,)),
]
async def _deny():
raise CredentialError("ACCOUNT NOT APPROVED")
@pytest.mark.parametrize("func,args", TOOL_CALLS, ids=[f.__name__ for f, _ in TOOL_CALLS])
def test_tool_returns_message_when_unauthorized(monkeypatch, func, args):
# override the autouse fixture: the caller has no usable credentials
monkeypatch.setattr(credentials, "resolve_caller_credentials", _deny)
result = asyncio.run(func(*args))
assert result == "ACCOUNT NOT APPROVED"
def test_all_tools_covered():
"""Guard: every registered MCP tool must appear in TOOL_CALLS.
Compares against the live tool registry instead of a hand-maintained count,
so adding a tool without adding its auth-gate test fails loudly here.
"""
from intervals_mcp_server.mcp_instance import mcp
registered = {t.name for t in asyncio.run(mcp.list_tools())}
covered = {f.__name__ for f, _ in TOOL_CALLS}
assert covered == registered, (
f"auth-gate matrix out of sync: missing={sorted(registered - covered)} "
f"extra={sorted(covered - registered)}"
)