From 537f01672bbe9c174c94749a898d11c2f40452d0 Mon Sep 17 00:00:00 2001 From: Snider Date: Mon, 26 Jan 2026 18:25:23 +0000 Subject: [PATCH] feat(database): create mcp_tool_versions table and model for versioned tool management --- ..._000004_create_mcp_tool_versions_table.php | 41 ++ .../src/Mod/Mcp/Models/McpToolVersion.php | 359 ++++++++++++++++++ 2 files changed, 400 insertions(+) create mode 100644 packages/core-mcp/src/Mod/Mcp/Migrations/2026_01_26_000004_create_mcp_tool_versions_table.php create mode 100644 packages/core-mcp/src/Mod/Mcp/Models/McpToolVersion.php diff --git a/packages/core-mcp/src/Mod/Mcp/Migrations/2026_01_26_000004_create_mcp_tool_versions_table.php b/packages/core-mcp/src/Mod/Mcp/Migrations/2026_01_26_000004_create_mcp_tool_versions_table.php new file mode 100644 index 0000000..9248f62 --- /dev/null +++ b/packages/core-mcp/src/Mod/Mcp/Migrations/2026_01_26_000004_create_mcp_tool_versions_table.php @@ -0,0 +1,41 @@ +id(); + $table->string('server_id', 64)->index(); + $table->string('tool_name', 128); + $table->string('version', 32); // semver: 1.0.0, 2.1.0-beta, etc. + $table->json('input_schema')->nullable(); + $table->json('output_schema')->nullable(); + $table->text('description')->nullable(); + $table->text('changelog')->nullable(); + $table->text('migration_notes')->nullable(); // guidance for upgrading from previous version + $table->boolean('is_latest')->default(false); + $table->timestamp('deprecated_at')->nullable(); + $table->timestamp('sunset_at')->nullable(); // after this date, version is blocked + $table->timestamps(); + + // Unique constraint: one version per tool per server + $table->unique(['server_id', 'tool_name', 'version'], 'mcp_tool_versions_unique'); + + // Index for finding latest versions + $table->index(['server_id', 'tool_name', 'is_latest'], 'mcp_tool_versions_latest'); + + // Index for finding deprecated/sunset versions + $table->index(['deprecated_at', 'sunset_at'], 'mcp_tool_versions_lifecycle'); + }); + } + + public function down(): void + { + Schema::dropIfExists('mcp_tool_versions'); + } +}; diff --git a/packages/core-mcp/src/Mod/Mcp/Models/McpToolVersion.php b/packages/core-mcp/src/Mod/Mcp/Models/McpToolVersion.php new file mode 100644 index 0000000..3bff53a --- /dev/null +++ b/packages/core-mcp/src/Mod/Mcp/Models/McpToolVersion.php @@ -0,0 +1,359 @@ + 'array', + 'output_schema' => 'array', + 'is_latest' => 'boolean', + 'deprecated_at' => 'datetime', + 'sunset_at' => 'datetime', + ]; + + // ------------------------------------------------------------------------- + // Scopes + // ------------------------------------------------------------------------- + + /** + * Filter by server. + */ + public function scopeForServer(Builder $query, string $serverId): Builder + { + return $query->where('server_id', $serverId); + } + + /** + * Filter by tool name. + */ + public function scopeForTool(Builder $query, string $toolName): Builder + { + return $query->where('tool_name', $toolName); + } + + /** + * Filter by specific version. + */ + public function scopeForVersion(Builder $query, string $version): Builder + { + return $query->where('version', $version); + } + + /** + * Get only latest versions. + */ + public function scopeLatest(Builder $query): Builder + { + return $query->where('is_latest', true); + } + + /** + * Get deprecated versions. + */ + public function scopeDeprecated(Builder $query): Builder + { + return $query->whereNotNull('deprecated_at') + ->where('deprecated_at', '<=', now()); + } + + /** + * Get sunset versions (blocked). + */ + public function scopeSunset(Builder $query): Builder + { + return $query->whereNotNull('sunset_at') + ->where('sunset_at', '<=', now()); + } + + /** + * Get active versions (not sunset). + */ + public function scopeActive(Builder $query): Builder + { + return $query->where(function ($q) { + $q->whereNull('sunset_at') + ->orWhere('sunset_at', '>', now()); + }); + } + + /** + * Order by version (newest first using semver sort). + */ + 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}" + ); + } + + // ------------------------------------------------------------------------- + // Accessors + // ------------------------------------------------------------------------- + + /** + * Check if this version is deprecated. + */ + public function getIsDeprecatedAttribute(): bool + { + return $this->deprecated_at !== null && $this->deprecated_at->isPast(); + } + + /** + * Check if this version is sunset (blocked). + */ + public function getIsSunsetAttribute(): bool + { + return $this->sunset_at !== null && $this->sunset_at->isPast(); + } + + /** + * Get the lifecycle status of this version. + */ + public function getStatusAttribute(): string + { + if ($this->is_sunset) { + return 'sunset'; + } + + if ($this->is_deprecated) { + return 'deprecated'; + } + + if ($this->is_latest) { + return 'latest'; + } + + return 'active'; + } + + /** + * Get full tool identifier (server:tool). + */ + public function getFullNameAttribute(): string + { + return "{$this->server_id}:{$this->tool_name}"; + } + + /** + * Get full versioned identifier (server:tool@version). + */ + public function getVersionedNameAttribute(): string + { + return "{$this->server_id}:{$this->tool_name}@{$this->version}"; + } + + // ------------------------------------------------------------------------- + // Methods + // ------------------------------------------------------------------------- + + /** + * Get deprecation warning message if deprecated but not sunset. + */ + public function getDeprecationWarning(): ?array + { + if (! $this->is_deprecated || $this->is_sunset) { + return null; + } + + $warning = [ + 'code' => 'TOOL_VERSION_DEPRECATED', + 'message' => "Tool version {$this->version} is deprecated.", + 'current_version' => $this->version, + ]; + + // Find the latest version to suggest + $latest = static::forServer($this->server_id) + ->forTool($this->tool_name) + ->latest() + ->first(); + + if ($latest && $latest->version !== $this->version) { + $warning['latest_version'] = $latest->version; + $warning['message'] .= " Please upgrade to version {$latest->version}."; + } + + if ($this->sunset_at) { + $warning['sunset_at'] = $this->sunset_at->toIso8601String(); + $warning['message'] .= " This version will be blocked after {$this->sunset_at->format('Y-m-d')}."; + } + + if ($this->migration_notes) { + $warning['migration_notes'] = $this->migration_notes; + } + + return $warning; + } + + /** + * Get sunset error if this version is blocked. + */ + public function getSunsetError(): ?array + { + if (! $this->is_sunset) { + return null; + } + + $error = [ + 'code' => 'TOOL_VERSION_SUNSET', + 'message' => "Tool version {$this->version} is no longer available as of {$this->sunset_at->format('Y-m-d')}.", + 'sunset_version' => $this->version, + 'sunset_at' => $this->sunset_at->toIso8601String(), + ]; + + // Find the latest version to suggest + $latest = static::forServer($this->server_id) + ->forTool($this->tool_name) + ->latest() + ->first(); + + if ($latest && $latest->version !== $this->version) { + $error['latest_version'] = $latest->version; + $error['message'] .= " Please use version {$latest->version} instead."; + } + + if ($this->migration_notes) { + $error['migration_notes'] = $this->migration_notes; + } + + return $error; + } + + /** + * Compare schemas between this version and another. + * + * @return array{added: array, removed: array, changed: array} + */ + public function compareSchemaWith(self $other): array + { + $thisProps = $this->input_schema['properties'] ?? []; + $otherProps = $other->input_schema['properties'] ?? []; + + $added = array_diff_key($otherProps, $thisProps); + $removed = array_diff_key($thisProps, $otherProps); + + $changed = []; + foreach (array_intersect_key($thisProps, $otherProps) as $key => $thisProp) { + $otherProp = $otherProps[$key]; + if (json_encode($thisProp) !== json_encode($otherProp)) { + $changed[$key] = [ + 'from' => $thisProp, + 'to' => $otherProp, + ]; + } + } + + return [ + 'added' => array_keys($added), + 'removed' => array_keys($removed), + 'changed' => $changed, + ]; + } + + /** + * Mark this version as deprecated. + */ + public function deprecate(?Carbon $sunsetAt = null): self + { + $this->deprecated_at = now(); + + if ($sunsetAt) { + $this->sunset_at = $sunsetAt; + } + + $this->save(); + + return $this; + } + + /** + * Mark this version as the latest (and unmark others). + */ + public function markAsLatest(): self + { + // Unmark all other versions for this tool + static::forServer($this->server_id) + ->forTool($this->tool_name) + ->where('id', '!=', $this->id) + ->update(['is_latest' => false]); + + $this->is_latest = true; + $this->save(); + + return $this; + } + + /** + * Export version info for API responses. + */ + public function toApiArray(): array + { + return [ + 'server_id' => $this->server_id, + 'tool_name' => $this->tool_name, + 'version' => $this->version, + 'is_latest' => $this->is_latest, + 'status' => $this->status, + 'description' => $this->description, + 'input_schema' => $this->input_schema, + 'output_schema' => $this->output_schema, + 'deprecated_at' => $this->deprecated_at?->toIso8601String(), + 'sunset_at' => $this->sunset_at?->toIso8601String(), + 'migration_notes' => $this->migration_notes, + 'changelog' => $this->changelog, + 'created_at' => $this->created_at?->toIso8601String(), + ]; + } +}