Skip to content

Commit 8e602b3

Browse files
gfraiteurclaude
andcommitted
Add MCP approval server for Docker-contained Claude instances
Implements a human-in-the-loop approval workflow for privileged host operations: - MCP server command (`tools mcp-server`) with HTTP transport - ExecuteCommand tool for running commands on host with approval - AI risk assessment using Claude CLI with situation-specific guidance - Spectre.Console approval UI with beep and title blink alerts - Docker integration: auto-start server, pass URL to container, auto-cleanup - CLAUDE.md documentation for when/how to use MCP server - `-NoMcp` flag in DockerBuild.ps1 to skip MCP server 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 02f2004 commit 8e602b3

16 files changed

+1312
-14
lines changed

CLAUDE.md

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,65 @@ The `postsharp-engineering` plugin provides skills and slash commands. To discov
1212
Before any work in this repo, read these skills:
1313
- `$env:USERPROFILE\.claude\plugins\cache\postsharp-engineering\**\skills\*.md` - Engineering workflows (git, builds, CI/CD)
1414
- Never update DockerBuild.ps1, Dockerfile. Dockerfile.claude, eng/RunClaude.ps1. These files are generated by `Build.ps1`. Their source code is in the Resources directory.
15-
- *ALWAYS* Read which plug-ins and skills are available to you before doing ANY work, and confirm to the users that you have read these skills before any session.
15+
- *ALWAYS* Read which plug-ins and skills are available to you before doing ANY work, and confirm to the users that you have read these skills before any session.
16+
17+
## MCP Approval Server (Docker Environment)
18+
19+
When running inside a Docker container, you have access to the `host-approval` MCP server for executing privileged commands on the host machine. These commands require human approval before execution.
20+
21+
### When to Use the MCP Server
22+
23+
Use the `ExecuteCommand` tool from the `host-approval` MCP server for:
24+
25+
#### GitHub Operations (requires host credentials)
26+
- `gh pr create` - Creating pull requests
27+
- `gh pr merge` - Merging pull requests
28+
- `gh pr view` - Viewing PR details (private repos)
29+
- `gh release create` - Creating releases
30+
- `gh issue create/close/comment` - Issue management
31+
- Any `gh` command that requires authentication
32+
33+
#### Git Push Operations
34+
- `git push` - Pushing commits to remote
35+
- `git push --tags` - Pushing tags
36+
- `git push --force` - Force pushing (use with caution)
37+
38+
#### TeamCity Operations
39+
- Any API calls to TeamCity
40+
- Triggering builds
41+
- Accessing build artifacts
42+
- Managing build configurations
43+
44+
#### Package Publishing
45+
- `dotnet nuget push` - Publishing NuGet packages
46+
- Any operation that publishes artifacts externally
47+
48+
### How to Use
49+
50+
Call the `ExecuteCommand` tool with:
51+
- `sessionId`: Use a consistent session ID for your conversation (e.g., "session-{timestamp}")
52+
- `command`: The command to execute
53+
- `workingDirectory`: The working directory (use forward slashes: `X:/src/RepoName`)
54+
- `claimedPurpose`: A clear explanation of why this command is needed
55+
56+
### Example
57+
58+
To push a feature branch:
59+
```
60+
ExecuteCommand(
61+
sessionId: "session-20241214",
62+
command: "git push origin feature/my-feature",
63+
workingDirectory: "X:/src/PostSharp.Engineering",
64+
claimedPurpose: "Push the feature branch with MCP implementation to remote for PR creation"
65+
)
66+
```
67+
68+
### What NOT to Use MCP For
69+
70+
Do NOT use the MCP server for:
71+
- Local git operations (commit, branch, checkout, status, diff, log)
72+
- Local builds (`dotnet build`, `dotnet test`)
73+
- File operations within the container
74+
- Reading files or exploring the codebase
75+
76+
These operations should be done directly in the container using standard tools.

Directory.Packages.props

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,10 @@
3939
<PackageVersion Include="System.Formats.Asn1" Version="9.0.0" />
4040
<PackageVersion Include="System.Management" Version="9.0.0" />
4141
<PackageVersion Include="System.Net.Http" Version="4.3.4" />
42-
<PackageVersion Include="System.Text.Json" Version="9.0.0" />
42+
<PackageVersion Include="System.Text.Json" Version="9.0.4" />
4343
<PackageVersion Include="System.Text.RegularExpressions" Version="4.3.1" />
4444
<PackageVersion Include="Typesense" Version="7.7.0" />
45+
<PackageVersion Include="ModelContextProtocol.AspNetCore" Version="0.1.0-preview.10" />
4546
<PackageVersion Include="Octokit" Version="13.0.1" />
4647
<PackageVersion Include="Octokit.GraphQL" Version="0.2.0-beta" />
4748
</ItemGroup>

DockerBuild.ps1

Lines changed: 189 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ param(
1010
[switch]$NoNuGetCache, # Does not mount the host nuget cache in the container.
1111
[switch]$KeepEnv, # Does not override the env.g.json file.
1212
[switch]$Claude, # Run Claude CLI instead of Build.ps1. Use -Claude for interactive, -Claude "prompt" for non-interactive.
13+
[switch]$NoMcp, # Do not start the MCP approval server (for -Claude mode).
1314
[string]$ImageName, # Image name (defaults to a name based on the directory).
1415
[string]$BuildAgentPath = 'C:\BuildAgent',
1516
[switch]$LoadEnvFromKeyVault, # Forces loading environment variables form the key vault.
@@ -332,6 +333,54 @@ if (Test-Path $sourceDependenciesDir)
332333
}
333334
}
334335

336+
# Mount sibling directories from the product family (parent directory)
337+
# Only if parent is a recognized product family (PostSharp* or Metalama*)
338+
$parentDir = Split-Path $SourceDirName -Parent
339+
$parentDirName = Split-Path $parentDir -Leaf
340+
if ($parentDir -and (Test-Path $parentDir) -and ($parentDirName -like "PostSharp*" -or $parentDirName -like "Metalama*"))
341+
{
342+
Write-Host "Detected product family directory: $parentDirName" -ForegroundColor Cyan
343+
$siblingDirs = Get-ChildItem -Path $parentDir -Directory -ErrorAction SilentlyContinue |
344+
Where-Object { $_.FullName -ne $SourceDirName }
345+
346+
foreach ($sibling in $siblingDirs)
347+
{
348+
$siblingPath = $sibling.FullName
349+
# Skip if already mounted
350+
$alreadyMounted = $VolumeMappings | Where-Object { $_ -like "*${siblingPath}:*" }
351+
if (-not $alreadyMounted)
352+
{
353+
Write-Host "Mounting product family sibling: $siblingPath" -ForegroundColor Cyan
354+
$VolumeMappings += @("-v", "${siblingPath}:${siblingPath}:ro")
355+
$MountPoints += $siblingPath
356+
$GitDirectories += $siblingPath
357+
}
358+
}
359+
}
360+
361+
# Mount PostSharp.Engineering.* directories from grandparent
362+
# This provides access to engineering tools and related repos
363+
$grandparentDir = Split-Path $parentDir -Parent
364+
if ($grandparentDir -and (Test-Path $grandparentDir))
365+
{
366+
$engineeringDirs = Get-ChildItem -Path $grandparentDir -Directory -Filter "PostSharp.Engineering*" -ErrorAction SilentlyContinue |
367+
Where-Object { $_.FullName -ne $SourceDirName }
368+
369+
foreach ($engDir in $engineeringDirs)
370+
{
371+
$engPath = $engDir.FullName
372+
# Skip if already mounted
373+
$alreadyMounted = $VolumeMappings | Where-Object { $_ -like "*${engPath}:*" }
374+
if (-not $alreadyMounted)
375+
{
376+
Write-Host "Mounting engineering repo: $engPath" -ForegroundColor Cyan
377+
$VolumeMappings += @("-v", "${engPath}:${engPath}:ro")
378+
$MountPoints += $engPath
379+
$GitDirectories += $engPath
380+
}
381+
}
382+
}
383+
335384
# Execute auto-generated DockerMounts.g.ps1 script to add more directory mounts.
336385
$dockerMountsScript = Join-Path $EngPath 'DockerMounts.g.ps1'
337386
if (Test-Path $dockerMountsScript)
@@ -440,6 +489,38 @@ foreach (`$dir in `$gitDirectories) {
440489
git config --global --add safe.directory `$normalizedDir
441490
}
442491
}
492+
493+
# Configure MCP approval server if available (for Claude mode)
494+
`$mcpServerUrl = [Environment]::GetEnvironmentVariable('MCP_APPROVAL_SERVER')
495+
if (`$mcpServerUrl) {
496+
Write-Host "Configuring MCP approval server: `$mcpServerUrl" -ForegroundColor Cyan
497+
498+
# Read existing settings or create new
499+
`$settingsPath = "`$env:USERPROFILE\.claude\settings.json"
500+
`$settingsDir = Split-Path `$settingsPath -Parent
501+
if (-not (Test-Path `$settingsDir)) {
502+
New-Item -ItemType Directory -Path `$settingsDir -Force | Out-Null
503+
}
504+
505+
if (Test-Path `$settingsPath) {
506+
`$settings = Get-Content `$settingsPath -Raw | ConvertFrom-Json -AsHashtable
507+
} else {
508+
`$settings = @{}
509+
}
510+
511+
# Add MCP server configuration
512+
if (-not `$settings.ContainsKey('mcpServers')) {
513+
`$settings['mcpServers'] = @{}
514+
}
515+
`$settings['mcpServers']['host-approval'] = @{
516+
'type' = 'http'
517+
'url' = `$mcpServerUrl
518+
}
519+
520+
# Write updated settings
521+
`$settings | ConvertTo-Json -Depth 10 | Set-Content `$settingsPath -Encoding UTF8
522+
Write-Host "MCP server configured in Claude settings" -ForegroundColor Green
523+
}
443524
"@
444525
$initScriptContent | Set-Content -Path $initScript -Encoding UTF8
445526

@@ -507,6 +588,56 @@ if (-not $BuildImage)
507588
{
508589
if ($Claude)
509590
{
591+
# Start MCP approval server on host with dynamic port in new terminal tab
592+
$mcpPort = $null
593+
$mcpPortFile = $null
594+
if (-not $NoMcp) {
595+
Write-Host "Starting MCP approval server..." -ForegroundColor Green
596+
$mcpPortFile = Join-Path $PSScriptRoot $dockerContextDirectory "mcp-port.txt"
597+
if (Test-Path $mcpPortFile) {
598+
Remove-Item $mcpPortFile
599+
}
600+
601+
# Build the command to run in the new tab
602+
$mcpCommand = "& '$SourceDirName\Build.ps1' tools mcp-server --port-file '$mcpPortFile'"
603+
604+
# Try Windows Terminal first (wt.exe), fall back to conhost
605+
$wtPath = Get-Command wt.exe -ErrorAction SilentlyContinue
606+
if ($wtPath) {
607+
# Open new tab in current Windows Terminal window
608+
# The -w 0 option targets the current window
609+
# Use single argument string for proper escaping
610+
$wtArgString = "-w 0 new-tab --title `"MCP Approval Server`" -- pwsh -NoExit -Command `"$mcpCommand`""
611+
$mcpServerProcess = Start-Process -FilePath "wt.exe" -ArgumentList $wtArgString -PassThru
612+
} else {
613+
# Fallback: start in new console window
614+
$mcpServerProcess = Start-Process -FilePath "pwsh" `
615+
-ArgumentList "-NoExit", "-Command", $mcpCommand `
616+
-PassThru
617+
}
618+
619+
# Wait for port file to be written (with timeout)
620+
$timeout = 30
621+
$elapsed = 0
622+
while (-not (Test-Path $mcpPortFile) -and $elapsed -lt $timeout) {
623+
Start-Sleep -Milliseconds 500
624+
$elapsed += 0.5
625+
}
626+
627+
if (-not (Test-Path $mcpPortFile)) {
628+
Write-Error "MCP server failed to start within $timeout seconds"
629+
if ($mcpServerProcess -and !$mcpServerProcess.HasExited) {
630+
Stop-Process -Id $mcpServerProcess.Id -Force -ErrorAction SilentlyContinue
631+
}
632+
exit 1
633+
}
634+
635+
$mcpPort = (Get-Content $mcpPortFile -Raw).Trim()
636+
Write-Host "MCP approval server running on port $mcpPort" -ForegroundColor Cyan
637+
} else {
638+
Write-Host "Skipping MCP approval server (-NoMcp specified)." -ForegroundColor Yellow
639+
}
640+
510641
# Run Claude mode
511642
Write-Host "Running Claude in the container." -ForegroundColor Green
512643

@@ -572,15 +703,67 @@ if (-not $BuildImage)
572703
$pwshPath = 'C:\Program Files\PowerShell\7\pwsh.exe'
573704

574705
# Set HOME/USERPROFILE so Claude finds its config in the mounted location
575-
$envArgs = @("-e", "HOME=$containerUserProfile", "-e", "USERPROFILE=$containerUserProfile")
706+
# Also pass MCP approval server URL if available
707+
$envArgs = @(
708+
"-e", "HOME=$containerUserProfile",
709+
"-e", "USERPROFILE=$containerUserProfile"
710+
)
711+
if ($mcpPort) {
712+
$envArgs += @("-e", "MCP_APPROVAL_SERVER=http://host.docker.internal:$mcpPort")
713+
}
576714

577-
Write-Host "Executing: ``docker run --rm --memory=12g $dockerArgsAsString $VolumeMappingsAsString -e HOME=$containerUserProfile -e USERPROFILE=$containerUserProfile -w $ContainerSourceDir $ImageTag `"$pwshPath`" -Command `"$inlineScript`"" -ForegroundColor Cyan
578-
docker run --rm --memory=12g $dockerArgs @VolumeMappings @envArgs -w $ContainerSourceDir $ImageTag $pwshPath -Command $inlineScript
715+
$mcpEnvDisplay = if ($mcpPort) { " -e MCP_APPROVAL_SERVER=http://host.docker.internal:$mcpPort" } else { "" }
716+
Write-Host "Executing: ``docker run --rm --memory=12g $dockerArgsAsString $VolumeMappingsAsString -e HOME=$containerUserProfile -e USERPROFILE=$containerUserProfile$mcpEnvDisplay -w $ContainerSourceDir $ImageTag `"$pwshPath`" -Command `"$inlineScript`"" -ForegroundColor Cyan
579717

580-
if ($LASTEXITCODE -ne 0)
718+
try {
719+
docker run --rm --memory=12g $dockerArgs @VolumeMappings @envArgs -w $ContainerSourceDir $ImageTag $pwshPath -Command $inlineScript
720+
$dockerExitCode = $LASTEXITCODE
721+
}
722+
finally {
723+
# Cleanup MCP server after container exits (only if it was started)
724+
if ($mcpPort) {
725+
Write-Host "Stopping MCP approval server..." -ForegroundColor Cyan
726+
727+
# Find the process listening on the MCP port and kill it
728+
try {
729+
# Find PID using netstat
730+
$netstatOutput = netstat -ano | Select-String ":$mcpPort\s" | Select-Object -First 1
731+
if ($netstatOutput) {
732+
$parts = $netstatOutput.Line.Trim() -split '\s+'
733+
$mcpPid = $parts[-1]
734+
if ($mcpPid -and $mcpPid -match '^\d+$') {
735+
Stop-Process -Id $mcpPid -Force -ErrorAction SilentlyContinue
736+
Write-Host "Stopped MCP server process (PID: $mcpPid)" -ForegroundColor Cyan
737+
}
738+
}
739+
} catch {
740+
Write-Host "Could not stop MCP server via port lookup: $_" -ForegroundColor Yellow
741+
}
742+
743+
# Fallback: try to find by command line
744+
$mcpProcesses = Get-Process -Name pwsh, dotnet -ErrorAction SilentlyContinue |
745+
Where-Object { $_.CommandLine -like "*mcp-server*" }
746+
747+
foreach ($proc in $mcpProcesses) {
748+
try {
749+
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
750+
Write-Host "Stopped MCP server process $($proc.Id)" -ForegroundColor Cyan
751+
} catch {
752+
# Process may have already exited
753+
}
754+
}
755+
}
756+
757+
# Clean up port file
758+
if ($mcpPortFile -and (Test-Path $mcpPortFile)) {
759+
Remove-Item $mcpPortFile -ErrorAction SilentlyContinue
760+
}
761+
}
762+
763+
if ($dockerExitCode -ne 0)
581764
{
582-
Write-Host "Docker run (Claude) failed with exit code $LASTEXITCODE" -ForegroundColor Red
583-
exit $LASTEXITCODE
765+
Write-Host "Docker run (Claude) failed with exit code $dockerExitCode" -ForegroundColor Red
766+
exit $dockerExitCode
584767
}
585768
}
586769
else

src/PostSharp.Engineering.BuildTools/AppExtensions.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
using PostSharp.Engineering.BuildTools.ContinuousIntegration;
1414
using PostSharp.Engineering.BuildTools.Dependencies;
1515
using PostSharp.Engineering.BuildTools.DotNetTools;
16+
using PostSharp.Engineering.BuildTools.Mcp;
1617
using PostSharp.Engineering.BuildTools.Tools;
1718
using PostSharp.Engineering.BuildTools.Tools.Csproj;
1819
using PostSharp.Engineering.BuildTools.Tools.Git;
@@ -281,6 +282,9 @@ internal static void AddCommands( this CommandApp app, Product product )
281282
"xmldoc",
282283
xmldoc => xmldoc.AddCommand<RemoveInternalsCommand>( "clean" ).WithDescription( "Remove internals." ).WithData( data ) );
283284

285+
tools.AddCommand<McpServerCommand>( "mcp-server" )
286+
.WithDescription( "Starts the MCP approval server for Docker containers" );
287+
284288
foreach ( var tool in product.DotNetTools )
285289
{
286290
tools.AddCommand<InvokeDotNetToolCommand>( tool.Alias )

0 commit comments

Comments
 (0)