Skip to content

fix(mcp): sort versions on a stored key, and stop shadowing the latest scope - #18

Merged
Snider merged 1 commit into
mainfrom
fix/version-sort-key
Aug 8, 2026
Merged

fix(mcp): sort versions on a stored key, and stop shadowing the latest scope#18
Snider merged 1 commit into
mainfrom
fix/version-sort-key

Conversation

@Snider

@Snider Snider commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

The sortable stored version key, as decided. Suite: 25 failed / 289 passed → 21 failed / 299 passed. ToolVersionServiceTest is now fully green.

The ordering

scopeOrderByVersion sorted with three nested SUBSTRING_INDEX calls — MySQL-only, so the scope could not execute on sqlite at all and nothing that ordered versions was testable. It also interpolated $direction straight into the raw string.

Versions now carry a normalised version_sort key, written on save and compared as a plain string:

1.2.3        ->  00001.00002.00003.~
10.0.0       ->  00010.00000.00000.~
1.0.0-beta   ->  00001.00000.00000.beta

Each component zero-padded to five digits, so 10.0.0 sorts above 9.9.9. The pre-release suffix follows a separator, with ~ (0x7E, above every alphanumeric) standing in for "no suffix" — so 1.0.0 outranks 1.0.0-beta, as semver requires.

Parsing happens once at write time instead of in every query. The human-readable version column stays display truth; ordering is a plain indexed ORDER BY; $direction is whitelisted to asc/desc.

The backfill uses chunkById, not chunk

The filter is whereNull('version_sort') and the loop fills that same column in, so every processed row leaves the result set. chunk() pages with OFFSET, so the set shrinking underneath it skips a page's worth for each page written. Measured on 1200 rows:

rows left unbackfilled
chunk() 500 of 1200
chunkById() 0

The defect underneath it

Fixing the ordering exposed a second, larger one.

The model defines scopeLatest. Illuminate's query builder already has latest(), and a real method always beats a local scope — so scopeLatest was never once called. Every caller meaning "the version flagged is_latest" silently got orderBy('created_at', 'desc'): which filters nothing, and for rows created in the same second does not even order deterministically. That is why getLatestVersion returned 1.0.0 after 2.0.0 had been explicitly marked latest.

Renamed to markedLatest so it cannot be shadowed. Five call sites meant it:

  • ToolVersionService::getLatestVersion
  • ToolRegistry's version enrichment
  • the two deprecation/sunset "suggest a newer version" lookups on the model
  • the admin ToolVersionManager filter — where status === 'latest' sat beside deprecated() and sunset() and had been filtering nothing at all

Tests

Six new cases cover the key directly: padding, semver ordering, pre-release precedence, missing components, malformed input, and that it follows a corrected version rather than going stale.

Noted, not included

While chasing the getLatestVersion failure I added a Cache::flush() to the base TestCaseCACHE_STORE is array and lives for the whole process while RefreshDatabase resets the database, so a cached model can outlive the row it came from. Two suites already flush by hand in their own setUp. It turned out not to be the cause here and changed no result, so it is left out rather than shipped as scope creep — but the order-dependency is real and worth its own change.

🤖 Generated with Claude Code
Co-Authored-By: Virgil virgil@lethean.io

Summary by CodeRabbit

  • Improvements

    • Improved version ordering for more reliable semver comparisons.
    • Correctly prioritised stable releases over prereleases when determining the latest version.
    • Added consistent handling for incomplete or malformed version numbers.
    • Updated latest-version selection across tool listings, version services and administration views.
  • Bug Fixes

    • Ensured version ordering remains consistent across supported database configurations.
    • Existing versions are automatically prepared for the improved ordering behaviour.

…t scope

scopeOrderByVersion sorted with three nested SUBSTRING_INDEX calls. That is
MySQL-only, so the scope could not execute on sqlite at all and nothing that
ordered versions was testable. It also interpolated $direction straight into
the raw string.

Versions now carry a normalised version_sort key, written on save and compared
as a plain string: each component zero-padded to five digits so "10.0.0" sorts
above "9.9.9", and the pre-release suffix appended after a separator with "~"
(0x7E, above every alphanumeric) standing in for "no suffix" so 1.0.0
outranks 1.0.0-beta as semver requires. Parsing happens once at write time
instead of in every query, and the human-readable `version` column stays the
display truth. Ordering is now a plain indexed ORDER BY and $direction is
whitelisted to asc/desc rather than interpolated.

The migration backfills existing rows with chunkById, not chunk. The filter is
whereNull('version_sort') and the loop fills that same column in, so every
processed row leaves the result set; chunk() pages with OFFSET and would skip
a page's worth for each page written. Measured on 1200 rows: chunk() left 500
of them null, chunkById left none.

Fixing the ordering exposed a second defect underneath it. The model defines
scopeLatest, but Illuminate's query builder already has latest(), and a real
method always beats a local scope — so scopeLatest was never once called.
Every caller meaning "the version flagged is_latest" silently got
orderBy('created_at', 'desc'), which filters nothing and, for rows created in
the same second, does not even order deterministically. That is why
getLatestVersion returned 1.0.0 after 2.0.0 had been explicitly marked latest.
Renamed to markedLatest so it cannot be shadowed, and the five call sites that
meant it are updated: getLatestVersion, ToolRegistry's version enrichment, the
two deprecation/sunset suggestions on the model, and the admin
ToolVersionManager filter — where status 'latest' sat beside deprecated() and
sunset() and had been filtering nothing at all.

ToolVersionServiceTest is now fully green, including the three that failed
before. Suite: 21 failed, 299 passed, from 25 failed, 289 passed. Six new
tests cover the key itself — padding, semver ordering, pre-release precedence,
missing components, malformed input, and that it follows a corrected version
rather than going stale.

Co-Authored-By: Virgil <virgil@lethean.io>
@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: aa24c0a0-24fe-4839-9c74-aca83b47b7fd

📥 Commits

Reviewing files that changed from the base of the PR and between 2041fb2 and 97df6a2.

📒 Files selected for processing (6)
  • php/src/Mcp/Migrations/2026_08_08_000001_add_version_sort_to_mcp_tool_versions.php
  • php/src/Mcp/Models/McpToolVersion.php
  • php/src/Mcp/Services/ToolRegistry.php
  • php/src/Mcp/Services/ToolVersionService.php
  • php/src/Mcp/View/Modal/Admin/ToolVersionManager.php
  • php/tests/Unit/VersionSortKeyTest.php

📝 Walkthrough

Walkthrough

Changes

The change adds a persisted, indexed version sort key. McpToolVersion generates the key during saves and uses it for portable ordering. Latest-version lookups now use markedLatest() across services, administration views, and warnings. A migration backfills existing records.

Version sorting and latest-version selection

Layer / File(s) Summary
Version sort storage
php/src/Mcp/Migrations/2026_08_08_000001_add_version_sort_to_mcp_tool_versions.php
The migration creates and indexes version_sort, backfills null values in stable ID chunks, and removes the column and index on rollback.
Model key generation and scope
php/src/Mcp/Models/McpToolVersion.php, php/tests/Unit/VersionSortKeyTest.php
McpToolVersion derives zero-padded sortable keys during saves, orders by the stored key, exposes markedLatest(), and updates warning queries. Tests cover ordering, malformed versions, and persistence.
Marked-latest integrations
php/src/Mcp/Services/ToolRegistry.php, php/src/Mcp/Services/ToolVersionService.php, php/src/Mcp/View/Modal/Admin/ToolVersionManager.php
Tool lookup, latest-version retrieval, and the latest administration filter now use markedLatest().

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant ToolVersionService
  participant McpToolVersion
  participant Database
  Caller->>ToolVersionService: request latest version
  ToolVersionService->>McpToolVersion: query markedLatest()
  McpToolVersion->>Database: filter is_latest and order by version_sort
  Database-->>McpToolVersion: return selected version
  McpToolVersion-->>ToolVersionService: return latest version
  ToolVersionService-->>Caller: return version
Loading

Warning

Billing warning: we have not been able to collect payment for this subscription for more than 72 hours. Please update the payment method or pay any pending invoices in Billing to avoid service interruption.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@Snider
Snider merged commit f9fb390 into main Aug 8, 2026
0 of 3 checks passed
@Snider
Snider deleted the fix/version-sort-key branch August 8, 2026 10:31
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