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
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
<?php

// SPDX-License-Identifier: EUPL-1.2

declare(strict_types=1);

use Core\Mcp\Models\McpToolVersion;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

/**
* Add a sortable version key to mcp_tool_versions.
*
* scopeOrderByVersion sorted with three nested SUBSTRING_INDEX calls, which is
* MySQL-only — the scope could not execute on sqlite at all, so nothing that
* ordered versions was testable. Storing a normalised key moves the parsing to
* write time and leaves every read a plain indexed ORDER BY, rather than
* spreading driver-specific SQL through every query that touches versions.
*
* The human-readable `version` column stays the display truth; this column is
* only ever compared.
*/
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('mcp_tool_versions')) {
return;
}

if (! Schema::hasColumn('mcp_tool_versions', 'version_sort')) {
Schema::table('mcp_tool_versions', function (Blueprint $table) {
$table->string('version_sort', 64)->nullable()->after('version');

$table->index(['server_id', 'tool_name', 'version_sort'], 'mcp_tool_versions_sort');
});
}

// Backfill existing rows, chunked rather than loaded whole: this table
// grows a row per tool per release and a deployed instance may hold a
// lot of them.
//
// chunkById, not chunk: the filter is whereNull('version_sort') and the
// loop fills that same column in, so each processed row leaves the
// result set. chunk() pages with OFFSET, so the set shrinking under it
// would skip a whole page's worth of rows for every page written —
// silently backfilling roughly half the table. chunkById pages with
// `where id > <last seen>`, which is stable under that mutation.
DB::table('mcp_tool_versions')
->select('id', 'version')
->whereNull('version_sort')
->chunkById(500, function ($rows) {
foreach ($rows as $row) {
DB::table('mcp_tool_versions')
->where('id', $row->id)
->update(['version_sort' => McpToolVersion::versionSortKey((string) $row->version)]);
}
});
}

public function down(): void
{
if (! Schema::hasColumn('mcp_tool_versions', 'version_sort')) {
return;
}

Schema::table('mcp_tool_versions', function (Blueprint $table) {
$table->dropIndex('mcp_tool_versions_sort');
$table->dropColumn('version_sort');
});
}
};
94 changes: 83 additions & 11 deletions php/src/Mcp/Models/McpToolVersion.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,21 @@ class McpToolVersion extends Model
'sunset_at' => 'datetime',
];

/**
* Keep the sortable key in step with the version on every write.
*
* On saving rather than creating: a version string can be corrected after
* the fact, and a key that silently stopped matching its version would be
* worse than no key at all. version_sort is derived, never assigned by a
* caller, so it is deliberately absent from $fillable.
*/
protected static function booted(): void
{
static::saving(function (self $model): void {
$model->version_sort = static::versionSortKey((string) $model->version);
});
}

// -------------------------------------------------------------------------
// Scopes
// -------------------------------------------------------------------------
Expand Down Expand Up @@ -89,9 +104,19 @@ public function scopeForVersion(Builder $query, string $version): Builder
}

/**
* Get only latest versions.
* Get only the version flagged is_latest.
*
* Named markedLatest, not latest: Illuminate's query builder already
* defines latest(), and a real method always wins over a local scope, so
* scopeLatest() was never once called. Every caller that meant "the marked
* latest version" silently got orderBy('created_at', 'desc') instead —
* which filters nothing, and on rows created in the same second does not
* even order deterministically.
*
* @example
* McpToolVersion::forTool('plan_create')->markedLatest()->first();
*/
public function scopeLatest(Builder $query): Builder
public function scopeMarkedLatest(Builder $query): Builder
{
return $query->where('is_latest', true);
}
Expand Down Expand Up @@ -127,16 +152,63 @@ public function scopeActive(Builder $query): Builder

/**
* Order by version (newest first using semver sort).
*
* Orders on the stored version_sort key, which is written at save time and
* compares correctly as a plain string. This was three nested
* SUBSTRING_INDEX calls in raw SQL — a MySQL-only construct, so the scope
* could not run on sqlite at all and nothing that ordered versions was
* testable. It also interpolated $direction straight into the raw string.
*
* @example
* McpToolVersion::forTool('plan_create')->orderByVersion()->first();
*/
public function scopeOrderByVersion(Builder $query, string $direction = 'desc'): Builder
{
// Basic version ordering - splits on dots and orders numerically
// For production use, consider a more robust semver sorting approach
return $query->orderByRaw(
"CAST(SUBSTRING_INDEX(version, '.', 1) AS UNSIGNED) {$direction}, ".
"CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(version, '.', 2), '.', -1) AS UNSIGNED) {$direction}, ".
"CAST(SUBSTRING_INDEX(SUBSTRING_INDEX(version, '.', 3), '.', -1) AS UNSIGNED) {$direction}"
);
// Whitelisted, not interpolated: $direction reaches the query builder as
// a direction keyword, and anything else is a caller bug, not a value.
$direction = strtolower($direction) === 'asc' ? 'asc' : 'desc';

return $query->orderBy('version_sort', $direction);
}

/**
* Build the sortable key for a semver string.
*
* Each numeric component is zero-padded to a fixed width so a plain string
* comparison matches numeric order — without padding "10.0.0" sorts before
* "9.0.0". The pre-release suffix is appended after a separator, and a
* release with no suffix gets "~" (0x7E), which sorts after every
* alphanumeric, so 1.0.0 correctly outranks 1.0.0-beta as semver requires.
*
* Non-numeric or missing components resolve to 0, so a malformed version
* still yields a comparable key rather than throwing on write.
*
* @example
* McpToolVersion::versionSortKey('1.2.3'); // '00001.00002.00003.~'
* McpToolVersion::versionSortKey('1.0.0-beta'); // '00001.00000.00000.beta'
*/
public static function versionSortKey(string $version): string
{
$version = trim($version);

// Split the pre-release/build suffix off the numeric core.
$core = $version;
$suffix = '';
foreach (['-', '+'] as $separator) {
$at = strpos($core, $separator);
if ($at !== false) {
$suffix = substr($core, $at + 1);
$core = substr($core, 0, $at);
}
}

$parts = explode('.', $core);
$key = '';
for ($i = 0; $i < 3; $i++) {
$key .= sprintf('%05d.', (int) ($parts[$i] ?? 0));
}

return $key.($suffix === '' ? '~' : $suffix);
}

// -------------------------------------------------------------------------
Expand Down Expand Up @@ -217,7 +289,7 @@ public function getDeprecationWarning(): ?array
// Find the latest version to suggest
$latest = static::forServer($this->server_id)
->forTool($this->tool_name)
->latest()
->markedLatest()
->first();

if ($latest && $latest->version !== $this->version) {
Expand Down Expand Up @@ -256,7 +328,7 @@ public function getSunsetError(): ?array
// Find the latest version to suggest
$latest = static::forServer($this->server_id)
->forTool($this->tool_name)
->latest()
->markedLatest()
->first();

if ($latest && $latest->version !== $this->version) {
Expand Down
2 changes: 1 addition & 1 deletion php/src/Mcp/Services/ToolRegistry.php
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ public function getToolsForServer(string $serverId, bool $includeVersionInfo = f
if ($includeVersionInfo) {
$latestVersion = McpToolVersion::forServer($serverId)
->forTool($name)
->latest()
->markedLatest()
->first();

if ($latestVersion) {
Expand Down
2 changes: 1 addition & 1 deletion php/src/Mcp/Services/ToolVersionService.php
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,7 @@ public function getLatestVersion(string $serverId, string $toolName): ?McpToolVe
// First try to find explicitly marked latest
$latest = McpToolVersion::forServer($serverId)
->forTool($toolName)
->latest()
->markedLatest()
->first();

if ($latest) {
Expand Down
2 changes: 1 addition & 1 deletion php/src/Mcp/View/Modal/Admin/ToolVersionManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ public function versions(): LengthAwarePaginator
}

if ($this->status === 'latest') {
$query->latest();
$query->markedLatest();
} elseif ($this->status === 'deprecated') {
$query->deprecated();
} elseif ($this->status === 'sunset') {
Expand Down
98 changes: 98 additions & 0 deletions php/tests/Unit/VersionSortKeyTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
<?php

// SPDX-License-Identifier: EUPL-1.2

declare(strict_types=1);

namespace Core\Mcp\Tests\Unit;

use Core\Mcp\Models\McpToolVersion;
use Tests\TestCase;

/**
* Covers the sortable version key that replaced the MySQL-only
* SUBSTRING_INDEX ordering in scopeOrderByVersion.
*
* The key is only ever compared, never displayed, so what matters is that
* sorting it as a plain string reproduces semver precedence.
*/
class VersionSortKeyTest extends TestCase
{
public function test_version_sort_key_good_pads_each_component(): void
{
$this->assertSame('00001.00002.00003.~', McpToolVersion::versionSortKey('1.2.3'));
$this->assertSame('00010.00000.00000.~', McpToolVersion::versionSortKey('10.0.0'));
}

public function test_version_sort_key_good_orders_semver_as_plain_strings(): void
{
$versions = ['2.0.0', '1.0.0', '10.0.0', '9.9.9', '1.1.0', '0.0.1'];

$keyed = [];
foreach ($versions as $version) {
$keyed[$version] = McpToolVersion::versionSortKey($version);
}
asort($keyed);

// Without the zero padding this is the case that breaks: "10.0.0"
// sorts before "9.9.9" on a naive string comparison.
$this->assertSame(
['0.0.1', '1.0.0', '1.1.0', '2.0.0', '9.9.9', '10.0.0'],
array_keys($keyed),
);
}

public function test_version_sort_key_good_ranks_a_release_above_its_prereleases(): void
{
$keyed = [
'1.0.0' => McpToolVersion::versionSortKey('1.0.0'),
'1.0.0-beta' => McpToolVersion::versionSortKey('1.0.0-beta'),
'1.0.0-alpha' => McpToolVersion::versionSortKey('1.0.0-alpha'),
];
asort($keyed);

// semver: a pre-release precedes the release it leads to. "~" is 0x7E,
// above every alphanumeric, which is what puts the bare release last.
$this->assertSame(['1.0.0-alpha', '1.0.0-beta', '1.0.0'], array_keys($keyed));
}

public function test_version_sort_key_bad_treats_missing_components_as_zero(): void
{
$this->assertSame('00001.00000.00000.~', McpToolVersion::versionSortKey('1'));
$this->assertSame('00001.00002.00000.~', McpToolVersion::versionSortKey('1.2'));
}

public function test_version_sort_key_ugly_still_returns_a_key_for_nonsense(): void
{
// A malformed version must not throw on write — a comparable key that
// sorts low is better than a failed save.
$this->assertSame('00000.00000.00000.~', McpToolVersion::versionSortKey(''));

// Non-numeric components resolve to zero, and anything after the first
// "-" is still read as a pre-release suffix, so garbage sorts below
// every real release rather than blowing up.
$this->assertSame('00000.00000.00000.a.version', McpToolVersion::versionSortKey('not-a.version'));
$this->assertSame('00000.00000.00000.~', McpToolVersion::versionSortKey('rubbish'));

$this->assertLessThan(
McpToolVersion::versionSortKey('0.0.1'),
McpToolVersion::versionSortKey('rubbish'),
);
}

public function test_version_sort_key_good_is_written_on_save(): void
{
$version = McpToolVersion::create([
'server_id' => 'test-server',
'tool_name' => 'test_tool',
'version' => '3.4.5',
]);

$this->assertSame('00003.00004.00005.~', $version->fresh()->version_sort);

// Derived, so it follows a corrected version rather than going stale.
$version->update(['version' => '4.0.0']);

$this->assertSame('00004.00000.00000.~', $version->fresh()->version_sort);
}
}
Loading