Skip to content

Implement new MCP protocol features#14

Merged
jamiew merged 10 commits into
mainfrom
mcp-upgrade
May 29, 2026
Merged

Implement new MCP protocol features#14
jamiew merged 10 commits into
mainfrom
mcp-upgrade

Conversation

@jamiew

@jamiew jamiew commented May 29, 2026

Copy link
Copy Markdown
Owner

a big modernization pass plus new mcp protocol features for the spotify server. trims the codebase to the fastmcp essentials, types the spotify boundary, bumps deps, then uses mcp 1.27.1 features the server wasn't touching. all backward compatible.

  • annotate all 15 tools with readonly/destructive/idempotent hints + human titles + a spotify icon so clients can reason about safety
  • give every tool a real output schema via pydantic result models instead of bare dict[str, Any] returns
  • stream progress + log notifications from get_playlist_tracks via Context for large playlists
  • confirm destructive playlist removals with ctx.elicit, falling through gracefully when the client doesn't support elicitation
  • keep the SpotifyMCPError code + suggestion in the surfaced message instead of flattening it
  • add spotify://track|playlist|artist|album/{id} resource templates
  • strip dead code from the fastmcp rewrite, drop the unused audio-features/recommendations tools, and type the spotify boundary with TypedDicts
  • bump deps to latest — mcp 1.27.1, spotipy 2.26, pytest 9, mypy 2.1, ruff 0.15
  • tests now hit 99% on the server module (multi-batch pagination, elicitation, resource templates, error mappings)

to test:

  • uv run pytest → 106 pass
  • uv run mypy src/ → clean

jamiew added 7 commits May 28, 2026 21:24
the server talks to the raw spotipy client directly, so the old Client
wrapper and its helpers were never reached. ~1400 lines of unused code +
their tests removed.

- spotify_api: drop all Client methods except __init__ (auth/token setup)
- utils: keep only normalize_redirect_uri; drop unused parse_* + validate
- errors: drop to_mcp_error, the handle_spotify_error decorators, and unused
  validation_error/no_active_device/premium_required helpers
- logging_utils: drop unused log_api_call and log_batch_operation
- rewrite the affected tests; test_spotify_api no longer wraps assertions in
  try/except so it actually verifies behavior
replaces dict[str, Any] at the parse/model-building boundaries so mypy
checks our key access against the actual Spotify object shapes.

- new spotify_types module describing the subset of fields we read
- type parse_track + the artist/album/playlist tools against them
- add `from __future__ import annotations` so the type-only imports stay
  under TYPE_CHECKING
every tool now has at least one success path and one failure path; adds the
previously untested tools and the resources/prompts. coverage 55% -> 95%.

- cover get_artist_info, get_album_info, get_saved_tracks,
  remove_tracks_from_playlist, modify_playlist_details
- add batch + over-limit cases for get_track_info, validation cases for
  modify_playlist_details, filtered-query + clamp cases for search
- assert SpotifyException is translated to ValueError per tool
- test the resources (current_user, current_playback) and the 3 prompts
- add sample_artist_data / sample_album_data fixtures
- drop the 3 empty placeholder tests
- start a changelog covering the 0.2.0 release, the dependency
  modernization, dead-code removal, and the coverage/type work
- define Followers before ArtistObject that references it
- make name required on ArtistRef/AlbumRef (always present in the API) so
  mypy catches name-less access
- use cast() instead of an assignment annotation in get_user_playlists to
  make the type assertion explicit
- add a limit-clamp test for get_saved_tracks
…elicitation)

builds on the mcp 1.27.1 SDK to use protocol affordances the server wasn't
touching yet — all backward compatible.

- add tool annotations (readOnly/destructive/idempotent hints) + human titles + a shared spotify icon to all 15 tools
- give every tool a real output schema via pydantic result models (SearchResults, QueueState, TrackList, ArtistInfo, AlbumInfo, PlaylistList, PlaylistTracks, SavedTracks, ActionResult)
- stream progress + log notifications from get_playlist_tracks via Context for large playlists
- confirm destructive removals with ctx.elicit, falling through gracefully when the client lacks elicitation support
- keep the SpotifyMCPError code + suggestion in the surfaced message instead of flattening it
- add spotify://track|playlist|artist|album/{id} resource templates
- make log_tool_execution async-aware so async tools time correctly
- expand tests for the new return models, async tools, elicitation, progress, and resource templates
closes the real coverage gaps surfaced while reviewing the new mcp features —
mocking stays at the spotipy boundary so the transforms run for real.

- exercise multi-batch playlist pagination (offset advancement, remaining-count termination, short-page stop, empty playlist, total fallback)
- cover elicitation accept/decline/unsupported and the accept-without-confirm cancel branch
- add success + error paths for all four resource templates plus the playback resource error
- cover the album and year_range search filters and non-track qtype conversion
- cover modify_playlist_details with a description
- cover the 403 playback-restricted, 404 user-not-found, and device-not-found error mappings
@jamiew jamiew changed the title Modernize the Spotify MCP server and adopt new protocol features Implement new MCP protocol features May 29, 2026
jamiew added 3 commits May 28, 2026 22:53
- only skip the removal-confirmation prompt when the client truly lacks
  elicitation support; if a capable client's prompt errors, propagate rather
  than silently deleting tracks
- read playback from current_playback() so device/volume/shuffle/repeat are
  populated instead of always null
- surface the snapshot_id returned by playlist add/remove
- tolerate null entries in search result items
- drop unused error codes left over from the deleted error helpers
- refresh stale "returns a dict" docstrings to name the typed models
- document structured output, annotations/icons, progress, and elicitation
  in CHANGELOG, README, and CLAUDE.md
- fix the stale "13 tools" count (now 11) and the "always return a dict"
  guidance (tools return typed pydantic models)
- correct the core-files map: spotify_api is OAuth-only, add spotify_types,
  utils is just redirect normalization
# Conflicts:
#	CHANGELOG.md
#	src/spotify_mcp/errors.py
#	src/spotify_mcp/fastmcp_server.py
#	src/spotify_mcp/logging_utils.py
#	tests/test_fastmcp_tools.py
@jamiew jamiew merged commit efaf66c into main May 29, 2026
7 checks passed
@jamiew jamiew deleted the mcp-upgrade branch May 29, 2026 04:38
@claude

claude Bot commented May 29, 2026

Copy link
Copy Markdown

Code Review

PR: Implement new MCP protocol features
Author: @jamiew


Overview

This is a solid modernization pass that upgrades the server to MCP 1.27.1 features. The core changes — typed Pydantic output models, tool annotations, progress notifications, elicitation for destructive ops, and new resource templates — are well-motivated and cleanly executed. The diff is large but the structure is consistent throughout.


What's Working Well

  • Elicitation safety model is correct. The three-way logic (unsupported client → skip prompt; supported client + error → propagate, don't delete; supported client + decline → cancel) handles all the cases that matter. The test suite covers each branch explicitly, including the subtle "accept with confirm=False" case.
  • current_playback() fix is a real improvement — the old current_user_playing_track() didn't return device/volume/shuffle/repeat, so tools were silently returning null for those fields.
  • log_tool_execution async/sync branching is clean. Using asyncio.iscoroutinefunction to select the right wrapper avoids any timing skew around coroutine creation.
  • Null guard in search_tracks (if item before parsing) is defensive and correct — Spotify does occasionally return null items for removed content.
  • snapshot_id surfacing in add_tracks_to_playlist and remove_tracks_from_playlist is a useful improvement for callers who want to verify playlist state.
  • Pydantic output model refactor is consistent — no dict[str, Any] returns remain, and the models are concise and well-named.

Issues

🔴 Critical: Async tests may not be executing

The async test methods (e.g. TestRemoveTracksFromPlaylist.test_converts_ids_and_uris, all async methods in TestGetPlaylistTracks) don't have @pytest.mark.asyncio decorators:

class TestRemoveTracksFromPlaylist:
    async def test_converts_ids_and_uris(self, mock_spotify_api):
        ...

Without either @pytest.mark.asyncio on each method or asyncio_mode = "auto" in pyproject.toml (under [tool.pytest.ini_options]), pytest-asyncio won't await these coroutines — it collects them as passed tests without ever running the assertions inside. The "106 pass" count can be entirely correct while none of these tests actually exercise the code.

pyproject.toml isn't in this diff, so please confirm asyncio_mode = "auto" is already set there. If it isn't, add:

[tool.pytest.ini_options]
asyncio_mode = "auto"

or decorate every async test with @pytest.mark.asyncio. This is the most important thing to verify before merge.


🟡 Minor: Track.added_at mutated after construction

In get_saved_tracks:

track = parse_track(item["track"])
track.added_at = item.get("added_at")
tracks.append(track)

Mutating a Pydantic model's field post-construction works (V2 models are mutable by default), but parse_track could accept an optional added_at parameter and pass it to the constructor directly — that would keep construction immutable and avoid the implicit reliance on the model not being frozen.


🟡 Minor: SearchResults.limit typed as int, not int | None

class SearchResults(BaseModel):
    limit: int

But it's populated with result_section.get("limit", limit). If result_section is an empty dict and limit itself is somehow None, Pydantic will raise a validation error at runtime. PlaylistTracks.limit is already typed int | None, so the pattern is established — consider aligning SearchResults.limit for consistency.


🟡 Minor: Resource handlers catch bare Exception

@mcp.resource("spotify://track/{track_id}", icons=[SPOTIFY_ICON])
def track_resource(track_id: str) -> str:
    try:
        return parse_track(spotify_client.track(track_id)).model_dump_json()
    except Exception as e:
        return json.dumps({"error": str(e)})

This is consistent with the existing current_user and current_playback_resource handlers, so it's not a regression. But it does mean AttributeError, TypeError, and other programming mistakes surface as {"error": "..."} instead of crashing visibly. At minimum, it's worth logging e at logger.exception(...) level before returning the JSON error so server-side errors aren't completely silent.


🔵 Nit: PR description says "15 tools", CLAUDE.md says "11 tools"

The PR body mentions "annotate all 15 tools" but the codebase (and the updated CLAUDE.md) consistently says 11. Minor, but worth correcting in the PR description.


Summary

The code is well-structured and the protocol features are implemented correctly. The one thing that must be verified before merge is whether the async tests are actually running — if asyncio_mode = "auto" isn't already in pyproject.toml, those tests are silently no-ops. Everything else is minor polish.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant