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:
@@ -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
|
||||
@@ -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
|
||||
@@ -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 теряются.'
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user