Files
nexus/scripts/qa/test-mcp2-compatibility.ps1
T
AzuTear f5552218bc
CI - Build & Test / Backend (.NET) (push) Successful in 42s
CI - Build & Test / Frontend (Vue/TS) (push) Successful in 2m46s
CI - Build & Test / Security Check (push) Successful in 3s
CI - Build & Test / Deploy Nexus (push) Successful in 56s
feat: ship agent-first mission control v0.2.57
2026-07-31 22:39:47 +02:00

241 lines
9.7 KiB
PowerShell

#requires -Version 7.0
[CmdletBinding()]
param(
[ValidateSet("Pin", "Baseline", "Candidate")]
[string]$Mode = "Pin",
[string]$CandidateVersion = "2.0.0",
[switch]$KeepTemporaryCopy
)
Set-StrictMode -Version Latest
$ErrorActionPreference = "Stop"
$stableVersion = "1.4.1"
$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).Path
$projectPath = Join-Path $repoRoot "backend\Nexus.Api.csproj"
$testProjectPath = Join-Path $repoRoot "backend-tests\Nexus.Api.Tests.csproj"
$projectHashBefore = (Get-FileHash -LiteralPath $projectPath -Algorithm SHA256).Hash
$temporaryRoot = $null
$dotnetExe = $null
function Get-McpPackageVersion {
param([Parameter(Mandatory)][string]$Path)
[xml]$project = Get-Content -LiteralPath $Path -Raw
$reference = @(
@($project.Project.ItemGroup.PackageReference) |
Where-Object { $_.Include -eq "ModelContextProtocol.AspNetCore" }
)
if ($reference.Count -ne 1) {
throw "Expected exactly one ModelContextProtocol.AspNetCore reference in $Path."
}
return $reference[0].GetAttribute("Version")
}
function Assert-StaticGate {
$version = Get-McpPackageVersion -Path $projectPath
if ($version -ne $stableVersion) {
throw "MCP production pin must remain $stableVersion; found $version."
}
$testFiles = @(
"backend-tests\McpServerConfigurationTests.cs",
"backend-tests\McpToolsTests.cs",
"backend-tests\AgentProposalServiceTests.cs"
)
foreach ($relativePath in $testFiles) {
$path = Join-Path $repoRoot $relativePath
if (-not (Test-Path -LiteralPath $path -PathType Leaf)) {
throw "Required MCP compatibility coverage is missing: $relativePath"
}
}
$toolSource = Get-Content -LiteralPath (Join-Path $repoRoot "backend\Services\NexusMcpTools.cs") -Raw
foreach ($marker in @(
"nexus_propose_agent",
"nexus_get_agent_proposal",
"UseStructuredContent = true",
"OutputSchemaType = typeof(AgentProposalToolResult)",
"Idempotent = true",
"OpenWorld = false"
)) {
if (-not $toolSource.Contains($marker, [StringComparison]::Ordinal)) {
throw "Required MCP contract marker is missing: $marker"
}
}
}
function Resolve-DotNet10 {
$candidates = @()
$pathDotnet = Get-Command dotnet -ErrorAction SilentlyContinue
if ($pathDotnet) {
$candidates += $pathDotnet.Source
}
if (-not [string]::IsNullOrWhiteSpace($env:USERPROFILE)) {
$candidates += (Join-Path $env:USERPROFILE ".dotnet\dotnet.exe")
}
foreach ($candidate in $candidates | Select-Object -Unique) {
if (-not (Test-Path -LiteralPath $candidate -PathType Leaf)) {
continue
}
$sdks = & $candidate --list-sdks
if ($LASTEXITCODE -eq 0 -and ($sdks | Where-Object { $_ -match "^10\." })) {
return (Resolve-Path -LiteralPath $candidate).Path
}
}
throw ".NET SDK 10 is required for Baseline and Candidate modes. Add it to PATH or install it under USERPROFILE\\.dotnet."
}
function Invoke-DotNetTest {
param(
[Parameter(Mandatory)][string]$Project,
[Parameter(Mandatory)][string]$WorkingDirectory
)
Push-Location $WorkingDirectory
try {
& $script:dotnetExe test $Project `
--configuration Release `
--verbosity minimal
if ($LASTEXITCODE -ne 0) {
throw "dotnet test failed for $Project."
}
} finally {
Pop-Location
}
}
function Copy-SourceTree {
param(
[Parameter(Mandatory)][string]$Source,
[Parameter(Mandatory)][string]$Destination
)
New-Item -ItemType Directory -Path $Destination -Force | Out-Null
$sourceRoot = [System.IO.Path]::GetFullPath($Source)
Get-ChildItem -LiteralPath $sourceRoot -Recurse -File |
Where-Object {
$relative = [System.IO.Path]::GetRelativePath($sourceRoot, $_.FullName)
$segments = $relative -split "[\\/]"
-not ($segments -contains "bin" -or $segments -contains "obj")
} |
ForEach-Object {
$relative = [System.IO.Path]::GetRelativePath($sourceRoot, $_.FullName)
$target = Join-Path $Destination $relative
$targetDirectory = Split-Path -Parent $target
New-Item -ItemType Directory -Path $targetDirectory -Force | Out-Null
Copy-Item -LiteralPath $_.FullName -Destination $target -Force
}
}
function Set-CandidatePackageVersion {
param(
[Parameter(Mandatory)][string]$Path,
[Parameter(Mandatory)][string]$Version
)
[xml]$project = Get-Content -LiteralPath $Path -Raw
$reference = @(
@($project.Project.ItemGroup.PackageReference) |
Where-Object { $_.Include -eq "ModelContextProtocol.AspNetCore" }
)
if ($reference.Count -ne 1) {
throw "Candidate copy does not contain exactly one MCP package reference."
}
$reference[0].SetAttribute("Version", $Version)
$project.Save($Path)
}
try {
Assert-StaticGate
Write-Host "PASS: MCP production dependency is pinned to $stableVersion and required source/test markers are present."
Write-Host "Static Pin mode did not execute tests or a live MCP/OpenClaw negotiation."
if ($Mode -eq "Pin") {
return
}
$dotnetExe = Resolve-DotNet10
# MSBuild launches helper applications (for example the OpenAPI document
# generator) through `dotnet`, not through the absolute executable used by
# this script. Keep those child processes on the same .NET 10 runtime.
$dotnetRoot = Split-Path -Parent $dotnetExe
$env:DOTNET_ROOT = $dotnetRoot
$pathSeparator = [System.IO.Path]::PathSeparator
if (-not (($env:PATH -split [Regex]::Escape([string]$pathSeparator)) -contains $dotnetRoot)) {
$env:PATH = "$dotnetRoot$pathSeparator$env:PATH"
}
Invoke-DotNetTest -Project $testProjectPath -WorkingDirectory $repoRoot
Write-Host "PASS: MCP $stableVersion baseline command completed successfully."
Write-Warning "Environment-gated Docker, Toxiproxy, or live OpenClaw tests may still be skipped; inspect the test summary before treating this as full compatibility evidence."
if ($Mode -eq "Baseline") {
return
}
if ($CandidateVersion -notmatch "^2\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$") {
throw "CandidateVersion must be an explicit MCP 2.x package version."
}
$temporaryRoot = Join-Path ([System.IO.Path]::GetTempPath()) "nexus-mcp2-gate-$([Guid]::NewGuid().ToString('N'))"
New-Item -ItemType Directory -Path $temporaryRoot -Force | Out-Null
Copy-SourceTree -Source (Join-Path $repoRoot "backend") -Destination (Join-Path $temporaryRoot "backend")
Copy-SourceTree -Source (Join-Path $repoRoot "backend-tests") -Destination (Join-Path $temporaryRoot "backend-tests")
$candidateProject = Join-Path $temporaryRoot "backend\Nexus.Api.csproj"
$candidateTests = Join-Path $temporaryRoot "backend-tests\Nexus.Api.Tests.csproj"
Set-CandidatePackageVersion -Path $candidateProject -Version $CandidateVersion
& $dotnetExe restore $candidateTests --force-evaluate
if ($LASTEXITCODE -ne 0) {
throw "MCP $CandidateVersion restore failed in the isolated copy."
}
$assetsPath = Join-Path $temporaryRoot "backend\obj\project.assets.json"
if (-not (Test-Path -LiteralPath $assetsPath -PathType Leaf)) {
throw "Candidate restore did not create project.assets.json."
}
$resolvedMarker = "`"ModelContextProtocol.AspNetCore/$CandidateVersion`""
$assets = Get-Content -LiteralPath $assetsPath -Raw
if (-not $assets.Contains($resolvedMarker, [StringComparison]::OrdinalIgnoreCase)) {
throw "Restore did not resolve the exact requested candidate $CandidateVersion."
}
Invoke-DotNetTest -Project $candidateTests -WorkingDirectory $temporaryRoot
if ($CandidateVersion.Contains("-", [StringComparison]::Ordinal)) {
Write-Warning "COMPATIBILITY PROBE PASS: $CandidateVersion compiled and passed all backend tests."
Write-Warning "PROMOTION GATE CLOSED: prerelease packages are not approved for production. The repository remains on $stableVersion."
} else {
Write-Host "SDK COMPATIBILITY PROBE PASS: stable candidate $CandidateVersion compiled and passed the backend suite."
Write-Warning "LIVE OPENCLAW GATE CLOSED: this probe does not test OpenClaw tools/list, client identity, Streamable HTTP, or protocol down-level negotiation."
Write-Host "The repository was not modified; live isolated OpenClaw compatibility evidence and a separate reviewed dependency change are still required."
}
} finally {
$projectHashAfter = (Get-FileHash -LiteralPath $projectPath -Algorithm SHA256).Hash
if ($projectHashAfter -ne $projectHashBefore) {
throw "Safety invariant violated: $projectPath changed during the compatibility gate."
}
if ($temporaryRoot -and (Test-Path -LiteralPath $temporaryRoot)) {
if ($KeepTemporaryCopy) {
Write-Host "Temporary candidate copy retained at $temporaryRoot"
} else {
$resolvedTemporaryRoot = [System.IO.Path]::GetFullPath($temporaryRoot)
$systemTemporaryRoot = [System.IO.Path]::GetFullPath([System.IO.Path]::GetTempPath())
if (
-not $resolvedTemporaryRoot.StartsWith($systemTemporaryRoot, [StringComparison]::OrdinalIgnoreCase) -or
-not ([System.IO.Path]::GetFileName($resolvedTemporaryRoot)).StartsWith(
"nexus-mcp2-gate-",
[StringComparison]::Ordinal)
) {
throw "Refusing to remove an unverified temporary path: $resolvedTemporaryRoot"
}
Remove-Item -LiteralPath $resolvedTemporaryRoot -Recurse -Force
}
}
}