Dev-лаунчер: сервер запускается из теневой копии сборки

Пока MCP-сервер работал прямо из src\...\bin, он держал открытым kompas-mcp.exe
и Kompas.Mcp.Core.dll — dotnet build падал с MSB3027 от каждой живой сессии.
Лаунчер копирует сборку в %LOCALAPPDATA%\kompas-mcp\dev\<отпечаток> и запускает
её оттуда, поэтому bin свободен всегда.

- tools/dev/KompasMcpDev.psm1 — отпечаток каталога сборки, теневая копия через
  атомарный Directory.Move, уборка старых копий мимо занятых живым процессом,
  сессионный mutex на одновременный старт нескольких сессий;
- tools/dev/launch-dev-mcp.ps1 — точка входа для .mcp.json, инвариант «в stdout
  до старта сервера не уходит ни байта»;
- tools/dev/rebuild-mcp.ps1 — -Check компилирует мимо bin (~2 с при живом
  сервере), обычный режим отказывается заранее и называет блокирующие PID;
- tools/tests/DevLauncher.Tests.ps1 — Pester на все три файла.

Раздел «The rebuild cycle» в CLAUDE.md (2c56511) уже описывал эту схему.
This commit is contained in:
2026-07-31 11:51:59 +03:00
parent 2c56511a7a
commit cb43b84249
5 changed files with 753 additions and 0 deletions
+269
View File
@@ -0,0 +1,269 @@
Set-StrictMode -Version Latest
# Теневой запуск dev-сборки MCP-сервера.
#
# Задача: разорвать связь между каталогом сборки и каталогом запуска. Пока сервер
# запущен прямо из src\...\bin\x64\Release\net8.0-windows, он держит открытым образ
# kompas-mcp.exe и Kompas.Mcp.Core.dll, и `dotnet build` падает с MSB3027/MSB3021
# («файл используется другим процессом»), причём сразу от всех живых сессий MCP.
# Лаунчер копирует сборку в приватный каталог и запускает сервер оттуда — bin остаётся
# свободным, собирать можно не закрывая сессию.
#
# ВАЖНО: ни одна функция этого модуля не пишет в success stream — stdout занят JSON-RPC.
$script:MarkerName = '.kompas-mcp-dev.json'
function Get-KompasMcpBuildFingerprint {
<#
.SYNOPSIS
Отпечаток каталога сборки: меняется при любой пересборке.
#>
[CmdletBinding()]
param([Parameter(Mandatory)] [string] $Directory)
if (-not (Test-Path -LiteralPath $Directory -PathType Container)) {
throw "нет каталога сборки: $Directory"
}
$root = (Resolve-Path -LiteralPath $Directory).ProviderPath.TrimEnd('\')
# @(...) обязательно: под Windows PowerShell 5.1 (в котором .mcp.json запускает
# лаунчер) Get-ChildItem с единственным файлом возвращает скаляр, и обращение
# к .Count под Set-StrictMode падает PropertyNotFoundException. В pwsh 7 то же
# выражение работает — то есть без обёртки баг не воспроизводится при локальной
# проверке и проявляется только у запущенного сервера.
$files = @(Get-ChildItem -LiteralPath $root -Recurse -File -Force -ErrorAction Stop)
if ($files.Count -eq 0) { throw "каталог сборки пуст: $Directory" }
# В отпечаток идут относительный путь, размер и время записи каждого файла:
# дёшево (без чтения содержимого) и ловит любую пересборку, включая ту, что
# вернула байт-в-байт тот же код при изменившемся составе файлов.
$lines = New-Object System.Collections.Generic.List[string]
foreach ($f in $files) {
$rel = $f.FullName.Substring($root.Length).TrimStart('\')
$lines.Add(('{0}|{1}|{2}' -f $rel.ToLowerInvariant(), $f.Length, $f.LastWriteTimeUtc.Ticks))
}
# Сортировка строго ordinal, а НЕ через Sort-Object: тот сортирует по правилам
# культуры, а Windows PowerShell 5.1 (NLS) и pwsh 7 (ICU) упорядочивают строки
# с пунктуацией по-разному — «kompas-mcp.exe» и «kompas.mcp.core.dll» меняются
# местами. Отпечаток тогда зависел бы от того, кто его считает: лаунчер (5.1)
# или rebuild-mcp.ps1 (7), и каталог теневой копии не совпал бы с отчётом сборки.
$lines.Sort([System.StringComparer]::Ordinal)
$payload = $lines -join "`n"
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$hash = $sha.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($payload))
}
finally { $sha.Dispose() }
# 16 hex-символов (64 бита) — коллизия неправдоподобна, а имя каталога читаемое.
return (-join ($hash[0..7] | ForEach-Object { $_.ToString('x2') }))
}
function Test-KompasMcpShadow {
<#
.SYNOPSIS
Годна ли теневая копия: есть exe и маркер с тем же отпечатком.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $Directory,
[Parameter(Mandatory)] [string] $Fingerprint
)
$exe = Join-Path $Directory 'kompas-mcp.exe'
$marker = Join-Path $Directory $script:MarkerName
if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) { return $false }
if (-not (Test-Path -LiteralPath $marker -PathType Leaf)) { return $false }
try {
$info = Get-Content -LiteralPath $marker -Raw | ConvertFrom-Json
}
catch {
# Любая ошибка чтения/разбора маркера (битый JSON, файл занят антивирусом,
# оборванная запись) означает одно: копию считаем негодной и делаем заново.
return $false
}
if ($null -eq $info) { return $false }
$prop = $info.PSObject.Properties['fingerprint']
if ($null -eq $prop) { return $false }
return ([string]$prop.Value -eq $Fingerprint)
}
function Get-KompasMcpRunningShadow {
<#
.SYNOPSIS
Каталоги, из которых прямо сейчас запущены процессы kompas-mcp (в нижнем регистре).
#>
[CmdletBinding()]
param()
$dirs = New-Object System.Collections.Generic.HashSet[string]
foreach ($p in (Get-Process -Name 'kompas-mcp' -ErrorAction SilentlyContinue)) {
try {
# .Path бросает Win32Exception для процесса чужого пользователя или
# уже умершего между перечислением и обращением — такой процесс просто
# не учитываем: хуже от этого только лишняя попытка уборки, которая
# сама по себе безопасна (все ошибки удаления гасятся).
if ($p.Path) { [void]$dirs.Add((Split-Path -Parent $p.Path).ToLowerInvariant()) }
}
catch { }
}
return $dirs
}
function Remove-KompasMcpStaleShadow {
<#
.SYNOPSIS
Убрать старые теневые копии (каждая ~10 МБ), не трогая используемые.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $Root,
[string] $Keep,
[timespan] $MaxAge = ([timespan]::FromDays(3))
)
# Уборка мусора не имеет права мешать запуску сервера: любая ошибка гасится,
# диагностика уходит в verbose stream — stdout занят JSON-RPC.
try {
if (-not (Test-Path -LiteralPath $Root)) { return }
$deadline = (Get-Date).ToUniversalTime() - $MaxAge
$busy = Get-KompasMcpRunningShadow
foreach ($dir in (Get-ChildItem -LiteralPath $Root -Directory -Force -ErrorAction SilentlyContinue)) {
try {
if ($Keep -and $dir.Name -eq $Keep) { continue }
if ($dir.Name -eq '.tmp') { continue }
# Каталог, из которого работает живой сервер, не трогаем даже если он
# старый: Remove-Item -Recurse снёс бы вспомогательные файлы вокруг
# залоченного exe и покалечил бы чужую сессию (сборки грузятся лениво,
# часть из них могла быть ещё не загружена).
if ($busy.Contains($dir.FullName.ToLowerInvariant())) { continue }
if ($dir.LastWriteTimeUtc -lt $deadline) {
Remove-Item -LiteralPath $dir.FullName -Recurse -Force -ErrorAction SilentlyContinue
}
}
catch {
Write-Verbose "не удалось убрать теневую копию $($dir.FullName): $($_.Exception.Message)"
}
}
}
catch {
Write-Verbose "не удалось обойти каталог теневых копий ${Root}: $($_.Exception.Message)"
}
}
function Copy-KompasMcpBuild {
<#
.SYNOPSIS
Разложить сборку в <InstallRoot>\<отпечаток>, если её там ещё нет. Возвращает каталог.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $SourceDirectory,
[Parameter(Mandatory)] [string] $InstallRoot,
[Parameter(Mandatory)] [string] $Fingerprint
)
$target = Join-Path $InstallRoot $Fingerprint
if (Test-KompasMcpShadow -Directory $target -Fingerprint $Fingerprint) { return $target }
if (-not (Test-Path -LiteralPath (Join-Path $SourceDirectory 'kompas-mcp.exe') -PathType Leaf)) {
throw "в каталоге сборки нет kompas-mcp.exe: $SourceDirectory. Соберите сервер: dotnet build -c Release"
}
# Временный каталог — под InstallRoot: перенос каталога атомарен только в пределах тома.
$tempRoot = Join-Path $InstallRoot '.tmp'
New-Item -ItemType Directory -Path $tempRoot -Force | Out-Null
$work = Join-Path $tempRoot ([guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $work -Force | Out-Null
try {
Copy-Item -LiteralPath $SourceDirectory -Destination $work -Recurse -Force -Container
# Copy-Item -Container кладёт исходный каталог ВНУТРЬ приёмника, а не разливает
# его содержимое: полезная нагрузка оказывается в подкаталоге с исходным именем.
$payload = Join-Path $work (Split-Path -Leaf $SourceDirectory)
if (-not (Test-Path -LiteralPath (Join-Path $payload 'kompas-mcp.exe') -PathType Leaf)) {
throw "копирование не воспроизвело kompas-mcp.exe в $payload"
}
$marker = [ordered]@{
fingerprint = $Fingerprint
source = $SourceDirectory
copiedAt = (Get-Date).ToString('o')
}
Set-Content -LiteralPath (Join-Path $payload $script:MarkerName) `
-Value ($marker | ConvertTo-Json) -Encoding utf8
New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null
if (Test-Path -LiteralPath $target) {
# Гонку с соседней сессией выиграл сосед — чужая копия уже на месте.
if (Test-KompasMcpShadow -Directory $target -Fingerprint $Fingerprint) { return $target }
throw "каталог теневой копии уже существует, но копия в нём негодная: $target"
}
try {
[System.IO.Directory]::Move($payload, $target)
}
catch [System.IO.IOException] {
# Гонку выиграл сосед — это успех, если его копия годная.
if (-not (Test-KompasMcpShadow -Directory $target -Fingerprint $Fingerprint)) { throw }
}
return $target
}
finally {
Remove-Item -LiteralPath $work -Recurse -Force -ErrorAction SilentlyContinue
}
}
function Resolve-KompasMcpDevExecutable {
<#
.SYNOPSIS
Путь к exe теневой копии текущей сборки (копируя её при необходимости).
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string] $SourceDirectory,
[string] $InstallRoot
)
if ([string]::IsNullOrWhiteSpace($InstallRoot)) {
$InstallRoot = Join-Path $env:LOCALAPPDATA 'kompas-mcp\dev'
}
$fp = Get-KompasMcpBuildFingerprint -Directory $SourceDirectory
# Сессионный mutex (без "Global\") — как в KompasMcpBootstrap.psm1: синхронизирует
# одновременный старт нескольких сессий MCP на одной и той же свежей сборке.
$mutex = New-Object System.Threading.Mutex($false, "kompas-mcp-dev-$fp")
$acquired = $false
try {
try {
$acquired = $mutex.WaitOne()
}
catch [System.Threading.AbandonedMutexException] {
# Предыдущий владелец умер посреди копирования — владение всё равно
# переходит к нам, продолжаем как обычно.
$acquired = $true
}
$dir = Copy-KompasMcpBuild -SourceDirectory $SourceDirectory -InstallRoot $InstallRoot -Fingerprint $fp
Remove-KompasMcpStaleShadow -Root $InstallRoot -Keep $fp
return (Join-Path $dir 'kompas-mcp.exe')
}
finally {
if ($acquired) { $mutex.ReleaseMutex() }
$mutex.Dispose()
}
}
Export-ModuleMember -Function Get-KompasMcpBuildFingerprint, Test-KompasMcpShadow, `
Get-KompasMcpRunningShadow, Remove-KompasMcpStaleShadow, Copy-KompasMcpBuild, `
Resolve-KompasMcpDevExecutable
+53
View File
@@ -0,0 +1,53 @@
#requires -Version 5.1
# Dev-точка входа MCP-сервера КОМПАС-3D: запуск из теневой копии локальной сборки.
# Прод-аналог (плагин, скачивание релиза по server.lock.json) — plugin/scripts/launch-kompas-mcp.ps1.
# ИНВАРИАНТ: до запуска сервера в stdout не уходит ни байта — stdout занят JSON-RPC.
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$exe = $null
try {
if (-not [string]::IsNullOrWhiteSpace($env:KOMPAS_MCP_EXE)) {
# Аварийный обход теневой копии: запуск строго указанного бинаря как есть.
$exe = $env:KOMPAS_MCP_EXE
}
else {
$repo = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
$source = $env:KOMPAS_MCP_DEV_BUILD_DIR
if ([string]::IsNullOrWhiteSpace($source)) {
$source = Join-Path $repo 'src\Kompas.Mcp.Host\bin\x64\Release\net8.0-windows'
}
if (-not (Test-Path -LiteralPath $source -PathType Container)) {
throw "нет каталога сборки: $source. Соберите сервер: dotnet build -c Release"
}
Import-Module (Join-Path $PSScriptRoot 'KompasMcpDev.psm1') -Force
$exe = Resolve-KompasMcpDevExecutable -SourceDirectory $source -InstallRoot $env:KOMPAS_MCP_DEV_ROOT
}
# -PathType Leaf: без него путь к существующему каталогу молча проходит проверку,
# а падает уже голый вызов "& $exe" сырым CommandNotFoundException в обход catch ниже.
if (-not (Test-Path -LiteralPath $exe -PathType Leaf)) {
throw "не найден сервер: $exe"
}
}
catch {
[Console]::Error.WriteLine("kompas-mcp dev-launcher: $($_.Exception.Message)")
exit 1
}
try {
& $exe @args
$serverExit = $LASTEXITCODE
}
catch {
# Сбой самого запуска (битый бинарь, недостающая зависимость, отказ в доступе) —
# не путать с ненулевым кодом возврата уже запущенного сервера, это не exception.
[Console]::Error.WriteLine("kompas-mcp dev-launcher: не удалось запустить сервер: $($_.Exception.Message)")
exit 1
}
exit $serverExit
+102
View File
@@ -0,0 +1,102 @@
#requires -Version 7.0
<#
.SYNOPSIS
Сборка MCP-сервера для dev-цикла: с диагностикой блокировки bin живыми сессиями.
.DESCRIPTION
Три режима, по возрастанию стоимости:
-Check компиляция мимо bin (сервер можно не останавливать) — «оно вообще собирается?»
(по умолчанию) полная сборка в bin + юнит-тесты — новый бинарь для теневого запуска
-NoTest то же без юнит-тестов
Каталог bin занят, пока хотя бы одна сессия MCP запущена ПРЯМО из него (см. .mcp.json).
С tools/dev/launch-dev-mcp.ps1 сервер работает из теневой копии, и bin свободен всегда.
.EXAMPLE
pwsh -NoProfile -File tools/dev/rebuild-mcp.ps1 -Check
.EXAMPLE
pwsh -NoProfile -File tools/dev/rebuild-mcp.ps1
#>
[CmdletBinding()]
param(
[switch] $Check,
[switch] $NoTest
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$repo = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)
$binDir = Join-Path $repo 'src\Kompas.Mcp.Host\bin\x64\Release\net8.0-windows'
function Get-BlockingProcess {
param([Parameter(Mandatory)] [string] $Directory)
$target = $Directory.TrimEnd('\').ToLowerInvariant()
$found = @()
foreach ($p in (Get-Process -Name 'kompas-mcp' -ErrorAction SilentlyContinue)) {
try {
if (-not $p.Path) { continue }
if ((Split-Path -Parent $p.Path).TrimEnd('\').ToLowerInvariant() -eq $target) { $found += $p }
}
catch {
# .Path недоступен (чужой пользователь / процесс уже умер) — считать такой
# процесс блокирующим нельзя, но и молчать нехорошо: сборка ниже всё равно
# покажет настоящего держателя файла в тексте MSB3027.
Write-Verbose "не удалось прочитать путь процесса $($p.Id): $($_.Exception.Message)"
}
}
return $found
}
if ($Check) {
# Стабильный (не случайный) каталог — чтобы работала инкрементальная сборка.
# Перенаправляем ТОЛЬКО BaseOutputPath: если увести ещё и BaseIntermediateOutputPath,
# старый obj\ в дереве остаётся видимым для glob'а и даёт CS0579 «Повторяющийся
# атрибут» на сгенерированном AssemblyInfo. Обратная сторона — obj\ остаётся общим
# с обычной сборкой, поэтому два параллельных прогона топчут инкрементальный кэш
# друг друга (безопасно, но лишняя перекомпиляция).
$stamp = [System.BitConverter]::ToString(
[System.Security.Cryptography.SHA256]::HashData(
[System.Text.Encoding]::UTF8.GetBytes($repo.ToLowerInvariant()))[0..3]).Replace('-', '')
$out = Join-Path ([System.IO.Path]::GetTempPath()) "kompas-mcp-check\$stamp\"
Write-Host "Проверка компиляции (bin не трогаем, выход → $out)" -ForegroundColor Cyan
dotnet build -c Release -p:BaseOutputPath=$out
exit $LASTEXITCODE
}
$blocking = Get-BlockingProcess -Directory $binDir
if ($blocking.Count -gt 0) {
Write-Host 'Сборка невозможна: bin занят живыми сессиями MCP.' -ForegroundColor Red
foreach ($p in $blocking) {
Write-Host (" PID {0}, запущен {1}" -f $p.Id, $p.StartTime.ToString('HH:mm:ss'))
}
Write-Host ''
Write-Host 'Варианты:' -ForegroundColor Yellow
Write-Host ' 1. Перевести .mcp.json на tools/dev/launch-dev-mcp.ps1 (теневой запуск) — тогда'
Write-Host ' bin свободен всегда, и эта ситуация больше не повторится.'
Write-Host ' 2. Закрыть все сессии Claude Code с этим MCP и повторить.'
Write-Host ' 3. Разовая проверка компиляции без остановки сервера: -Check'
exit 2
}
Write-Host 'Сборка (Release)…' -ForegroundColor Cyan
dotnet build -c Release
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
if (-not $NoTest) {
Write-Host 'Юнит-тесты…' -ForegroundColor Cyan
dotnet test --filter 'Category=Unit' --nologo
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
}
Import-Module (Join-Path $PSScriptRoot 'KompasMcpDev.psm1') -Force
$fp = Get-KompasMcpBuildFingerprint -Directory $binDir
Write-Host ''
Write-Host "Готово. Отпечаток сборки: $fp" -ForegroundColor Green
Write-Host 'Сервер подхватит её при следующем запуске процесса MCP:'
Write-Host ' /mcp → переподключить kompas (если клиент умеет), иначе — перезапуск сессии.'
Write-Host 'После переподключения: kompas_status покажет живое соединение; эскизы по id теряются.'
+329
View File
@@ -0,0 +1,329 @@
BeforeAll {
$script:DevRoot = Join-Path $PSScriptRoot '..' 'dev'
$script:Launcher = Join-Path $script:DevRoot 'launch-dev-mcp.ps1'
Import-Module (Join-Path $script:DevRoot 'KompasMcpDev.psm1') -Force
function New-TempDir {
$d = Join-Path ([System.IO.Path]::GetTempPath()) ('kompasdev_' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $d -Force | Out-Null
return $d
}
# Фейковая «сборка»: cmd.exe под именем kompas-mcp.exe. Даёт настоящий запускаемый
# процесс, поведением которого можно управлять аргументами (/c echo …, /c exit N).
function New-FakeBuild {
$dir = New-TempDir
Copy-Item -LiteralPath $env:ComSpec -Destination (Join-Path $dir 'kompas-mcp.exe')
Set-Content -LiteralPath (Join-Path $dir 'Kompas.Mcp.Core.dll') -Value 'stub' -Encoding ascii
return $dir
}
function Invoke-DevLauncher {
param([string[]] $LauncherArgs = @())
$stdOut = [System.IO.Path]::GetTempFileName()
$stdErr = [System.IO.Path]::GetTempFileName()
# powershell (5.1), а не pwsh: именно так лаунчер запускается из .mcp.json.
$psArgs = @('-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-File', $script:Launcher) + $LauncherArgs
$proc = Start-Process -FilePath 'powershell' -ArgumentList $psArgs -NoNewWindow -Wait -PassThru `
-RedirectStandardOutput $stdOut -RedirectStandardError $stdErr
return [pscustomobject]@{
ExitCode = $proc.ExitCode
StdOutPath = $stdOut
StdErrPath = $stdErr
}
}
function Remove-LauncherResult {
param($Result)
Remove-Item -LiteralPath $Result.StdOutPath, $Result.StdErrPath -ErrorAction SilentlyContinue
}
}
Describe 'Get-KompasMcpBuildFingerprint' {
It 'стабилен для неизменного каталога' {
$dir = New-FakeBuild
try {
Get-KompasMcpBuildFingerprint -Directory $dir |
Should -Be (Get-KompasMcpBuildFingerprint -Directory $dir)
} finally { Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'меняется, когда файл пересобран' {
$dir = New-FakeBuild
try {
$before = Get-KompasMcpBuildFingerprint -Directory $dir
Set-Content -LiteralPath (Join-Path $dir 'Kompas.Mcp.Core.dll') -Value 'stub v2' -Encoding ascii
Get-KompasMcpBuildFingerprint -Directory $dir | Should -Not -Be $before
} finally { Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'меняется, когда в сборке появился новый файл' {
$dir = New-FakeBuild
try {
$before = Get-KompasMcpBuildFingerprint -Directory $dir
Set-Content -LiteralPath (Join-Path $dir 'Extra.dll') -Value 'x' -Encoding ascii
Get-KompasMcpBuildFingerprint -Directory $dir | Should -Not -Be $before
} finally { Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'совпадает между Windows PowerShell 5.1 и pwsh 7' {
# Лаунчер считает отпечаток под 5.1 (так его запускает .mcp.json), а
# rebuild-mcp.ps1 — под 7. Разойдись они, имя каталога теневой копии не
# совпало бы с тем, что сообщает сборка. Ровно это и происходило, пока
# строки сортировались через Sort-Object: 5.1 (NLS) и 7 (ICU) упорядочивают
# пунктуацию по-разному.
$dir = New-FakeBuild
try {
$module = Join-Path $script:DevRoot 'KompasMcpDev.psm1'
$fp51 = & powershell -NoProfile -NonInteractive -Command `
"Import-Module '$module' -Force; Get-KompasMcpBuildFingerprint -Directory '$dir'"
$fp51 | Should -Be (Get-KompasMcpBuildFingerprint -Directory $dir)
} finally { Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'падает с внятным сообщением на несуществующем каталоге' {
{ Get-KompasMcpBuildFingerprint -Directory 'C:\нет\такого\каталога' } |
Should -Throw -ExpectedMessage '*нет каталога сборки*'
}
It 'работает под 5.1, когда в каталоге ровно один файл' {
# Скалярный (не массивом) результат Get-ChildItem под Set-StrictMode в 5.1 —
# обращение к .Count даёт PropertyNotFoundException. В pwsh 7 не воспроизводится.
$dir = New-TempDir
try {
Copy-Item -LiteralPath $env:ComSpec -Destination (Join-Path $dir 'kompas-mcp.exe')
$module = Join-Path $script:DevRoot 'KompasMcpDev.psm1'
$out = & powershell -NoProfile -NonInteractive -Command `
"Import-Module '$module' -Force; Get-KompasMcpBuildFingerprint -Directory '$dir'" 2>&1
($out | Out-String) | Should -Not -Match 'PropertyNotFound'
($out | Out-String).Trim() | Should -Match '^[0-9a-f]{16}$'
} finally { Remove-Item -LiteralPath $dir -Recurse -Force -ErrorAction SilentlyContinue }
}
}
Describe 'Copy-KompasMcpBuild' {
# Имена тестов Pester разворачивает как интерполируемые строки, поэтому в них
# не должно быть $-переменных и угловых «плейсхолдеров» вида <root>: под
# Set-StrictMode (его включает run-ps-tests.ps1) такое имя валит тест ещё до тела.
It 'раскладывает сборку в каталог с именем-отпечатком и помечает маркером' {
$src = New-FakeBuild
$root = New-TempDir
try {
$fp = Get-KompasMcpBuildFingerprint -Directory $src
$target = Copy-KompasMcpBuild -SourceDirectory $src -InstallRoot $root -Fingerprint $fp
(Split-Path -Leaf $target) | Should -Be $fp
Test-Path -LiteralPath (Join-Path $target 'kompas-mcp.exe') | Should -BeTrue
Test-Path -LiteralPath (Join-Path $target 'Kompas.Mcp.Core.dll') | Should -BeTrue
Test-KompasMcpShadow -Directory $target -Fingerprint $fp | Should -BeTrue
} finally {
Remove-Item -LiteralPath $src, $root -Recurse -Force -ErrorAction SilentlyContinue
}
}
It 'повторный вызов не копирует заново' {
$src = New-FakeBuild
$root = New-TempDir
try {
$fp = Get-KompasMcpBuildFingerprint -Directory $src
$target = Copy-KompasMcpBuild -SourceDirectory $src -InstallRoot $root -Fingerprint $fp
$marker = Join-Path $target '.kompas-mcp-dev.json'
$stampBefore = (Get-Item -LiteralPath $marker).LastWriteTimeUtc
Copy-KompasMcpBuild -SourceDirectory $src -InstallRoot $root -Fingerprint $fp | Should -Be $target
(Get-Item -LiteralPath $marker).LastWriteTimeUtc | Should -Be $stampBefore
} finally {
Remove-Item -LiteralPath $src, $root -Recurse -Force -ErrorAction SilentlyContinue
}
}
It 'не оставляет мусор в .tmp' {
$src = New-FakeBuild
$root = New-TempDir
try {
$fp = Get-KompasMcpBuildFingerprint -Directory $src
Copy-KompasMcpBuild -SourceDirectory $src -InstallRoot $root -Fingerprint $fp | Out-Null
$tmp = Join-Path $root '.tmp'
if (Test-Path -LiteralPath $tmp) {
# @(...) — пустой каталог даёт $null, и .Count под StrictMode падает.
@(Get-ChildItem -LiteralPath $tmp -Force).Count | Should -Be 0
}
} finally {
Remove-Item -LiteralPath $src, $root -Recurse -Force -ErrorAction SilentlyContinue
}
}
It 'падает с внятным сообщением, когда в каталоге сборки нет kompas-mcp.exe' {
$src = New-TempDir
$root = New-TempDir
try {
Set-Content -LiteralPath (Join-Path $src 'readme.txt') -Value 'x' -Encoding ascii
{ Copy-KompasMcpBuild -SourceDirectory $src -InstallRoot $root -Fingerprint 'deadbeefdeadbeef' } |
Should -Throw -ExpectedMessage '*нет kompas-mcp.exe*'
} finally {
Remove-Item -LiteralPath $src, $root -Recurse -Force -ErrorAction SilentlyContinue
}
}
}
Describe 'Test-KompasMcpShadow' {
It 'отвергает копию с чужим отпечатком' {
$src = New-FakeBuild
$root = New-TempDir
try {
$fp = Get-KompasMcpBuildFingerprint -Directory $src
$target = Copy-KompasMcpBuild -SourceDirectory $src -InstallRoot $root -Fingerprint $fp
Test-KompasMcpShadow -Directory $target -Fingerprint 'aaaaaaaaaaaaaaaa' | Should -BeFalse
} finally {
Remove-Item -LiteralPath $src, $root -Recurse -Force -ErrorAction SilentlyContinue
}
}
It 'отвергает копию без маркера (оборванное копирование)' {
$src = New-FakeBuild
$root = New-TempDir
try {
$fp = Get-KompasMcpBuildFingerprint -Directory $src
$target = Copy-KompasMcpBuild -SourceDirectory $src -InstallRoot $root -Fingerprint $fp
Remove-Item -LiteralPath (Join-Path $target '.kompas-mcp-dev.json') -Force
Test-KompasMcpShadow -Directory $target -Fingerprint $fp | Should -BeFalse
} finally {
Remove-Item -LiteralPath $src, $root -Recurse -Force -ErrorAction SilentlyContinue
}
}
It 'отвергает копию с повреждённым маркером' {
$src = New-FakeBuild
$root = New-TempDir
try {
$fp = Get-KompasMcpBuildFingerprint -Directory $src
$target = Copy-KompasMcpBuild -SourceDirectory $src -InstallRoot $root -Fingerprint $fp
Set-Content -LiteralPath (Join-Path $target '.kompas-mcp-dev.json') -Value '{ это не json' -Encoding utf8
Test-KompasMcpShadow -Directory $target -Fingerprint $fp | Should -BeFalse
} finally {
Remove-Item -LiteralPath $src, $root -Recurse -Force -ErrorAction SilentlyContinue
}
}
}
Describe 'Remove-KompasMcpStaleShadow' {
It 'убирает старую копию, но не текущую и не .tmp' {
$root = New-TempDir
try {
$old = Join-Path $root 'aaaaaaaaaaaaaaaa'
$keep = Join-Path $root 'bbbbbbbbbbbbbbbb'
$tmp = Join-Path $root '.tmp'
foreach ($d in @($old, $keep, $tmp)) { New-Item -ItemType Directory -Path $d -Force | Out-Null }
$past = (Get-Date).AddDays(-30)
(Get-Item -LiteralPath $old).LastWriteTime = $past
(Get-Item -LiteralPath $tmp).LastWriteTime = $past
Remove-KompasMcpStaleShadow -Root $root -Keep 'bbbbbbbbbbbbbbbb' -MaxAge ([timespan]::FromDays(3))
Test-Path -LiteralPath $old | Should -BeFalse
Test-Path -LiteralPath $keep | Should -BeTrue
Test-Path -LiteralPath $tmp | Should -BeTrue
} finally { Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue }
}
It 'молча переживает отсутствующий каталог' {
{ Remove-KompasMcpStaleShadow -Root 'C:\нет\такого\каталога' } | Should -Not -Throw
}
}
Describe 'launch-dev-mcp.ps1' {
It 'запускает сервер из теневой копии, а не из каталога сборки' {
$src = New-FakeBuild
$root = New-TempDir
$env:KOMPAS_MCP_DEV_BUILD_DIR = $src
$env:KOMPAS_MCP_DEV_ROOT = $root
try {
# cmd /c echo %~dp0 не годится (это путь скрипта, а не exe), поэтому просто
# убеждаемся: теневая копия создалась и сервер запустился именно после неё.
$result = Invoke-DevLauncher -LauncherArgs @('/c', 'echo', 'PONG')
try {
$result.ExitCode | Should -Be 0
(Get-Content -LiteralPath $result.StdOutPath -Raw) | Should -Be "PONG`r`n"
$fp = Get-KompasMcpBuildFingerprint -Directory $src
Test-Path -LiteralPath (Join-Path $root "$fp\kompas-mcp.exe") | Should -BeTrue
} finally { Remove-LauncherResult $result }
} finally {
Remove-Item Env:\KOMPAS_MCP_DEV_BUILD_DIR, Env:\KOMPAS_MCP_DEV_ROOT -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $src, $root -Recurse -Force -ErrorAction SilentlyContinue
}
}
It 'оставляет каталог сборки незалоченным: его можно удалить, пока сервер работает' {
$src = New-FakeBuild
$root = New-TempDir
$env:KOMPAS_MCP_DEV_BUILD_DIR = $src
$env:KOMPAS_MCP_DEV_ROOT = $root
try {
$result = Invoke-DevLauncher -LauncherArgs @('/c', 'exit', '0')
Remove-LauncherResult $result
# Ровно то, ради чего затеян теневой запуск: после старта сервера каталог
# сборки свободен — MSBuild сможет перезаписать его, не дожидаясь выхода сессии.
{ Remove-Item -LiteralPath $src -Recurse -Force } | Should -Not -Throw
} finally {
Remove-Item Env:\KOMPAS_MCP_DEV_BUILD_DIR, Env:\KOMPAS_MCP_DEV_ROOT -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $src, $root -Recurse -Force -ErrorAction SilentlyContinue
}
}
It 'пробрасывает код возврата сервера' {
$src = New-FakeBuild
$root = New-TempDir
$env:KOMPAS_MCP_DEV_BUILD_DIR = $src
$env:KOMPAS_MCP_DEV_ROOT = $root
try {
$result = Invoke-DevLauncher -LauncherArgs @('/c', 'exit', '42')
try { $result.ExitCode | Should -Be 42 } finally { Remove-LauncherResult $result }
} finally {
Remove-Item Env:\KOMPAS_MCP_DEV_BUILD_DIR, Env:\KOMPAS_MCP_DEV_ROOT -ErrorAction SilentlyContinue
Remove-Item -LiteralPath $src, $root -Recurse -Force -ErrorAction SilentlyContinue
}
}
It 'уважает KOMPAS_MCP_EXE в обход теневой копии' {
$env:KOMPAS_MCP_EXE = $env:ComSpec
try {
$result = Invoke-DevLauncher -LauncherArgs @('/c', 'echo', 'PONG')
try {
(Get-Content -LiteralPath $result.StdOutPath -Raw) | Should -Be "PONG`r`n"
} finally { Remove-LauncherResult $result }
} finally { Remove-Item Env:\KOMPAS_MCP_EXE -ErrorAction SilentlyContinue }
}
It 'падает с кодом 1 и подсказкой про dotnet build, когда сборки нет' {
$env:KOMPAS_MCP_DEV_BUILD_DIR = 'C:\нет\такого\bin'
try {
$result = Invoke-DevLauncher
try {
$result.ExitCode | Should -Be 1
$stderrText = Get-Content -LiteralPath $result.StdErrPath -Raw
$stderrText | Should -Match 'kompas-mcp dev-launcher'
$stderrText | Should -Match 'dotnet build'
} finally { Remove-LauncherResult $result }
} finally { Remove-Item Env:\KOMPAS_MCP_DEV_BUILD_DIR -ErrorAction SilentlyContinue }
}
It 'на пути отказа не пишет в stdout ни одного байта' {
$env:KOMPAS_MCP_DEV_BUILD_DIR = 'C:\нет\такого\bin'
try {
$result = Invoke-DevLauncher
try {
$result.ExitCode | Should -Be 1
# Один лишний байт в stdout ломает JSON-RPC на всю MCP-сессию —
# самый критичный инвариант лаунчера, проверяем по длине файла.
(Get-Item -LiteralPath $result.StdOutPath).Length | Should -Be 0
} finally { Remove-LauncherResult $result }
} finally { Remove-Item Env:\KOMPAS_MCP_DEV_BUILD_DIR -ErrorAction SilentlyContinue }
}
}