mcp/gitbox-mcp.ps1
|
#requires -Version 7.0 <# .SYNOPSIS Model Context Protocol (MCP) server exposing the gitbox orchestrator. .DESCRIPTION Speaks MCP over stdio (newline-delimited JSON-RPC 2.0) and forwards tool calls to the installed gitbox module. The server's own stdout is reserved for the protocol; every gitbox invocation runs in a child pwsh process so its Write-Host / spinner output is captured as text and returned in the tool result rather than corrupting the JSON-RPC stream. Mutating merge and release flags (m, n, z) and any workflow that expands to them are refused unless the server is started with -AllowMerge or the environment variable GITBOX_MCP_ALLOW_MERGE is set to a truthy value. .PARAMETER AllowMerge Permit merge/release operations. Off by default. .PARAMETER RepoPath Repository the tools operate in. Defaults to the process working directory, which an MCP client normally sets per project. .PARAMETER ModulePath Optional path to a gitbox.psd1 to import instead of the PATH-resolved module. Useful for running against a working tree during development. #> [CmdletBinding()] param( [switch]$AllowMerge, [string]$RepoPath = (Get-Location).Path, [string]$ModulePath ) $ErrorActionPreference = 'Stop' $script:AllowMerge = $AllowMerge.IsPresent -or ("$($env:GITBOX_MCP_ALLOW_MERGE)".ToLower() -in '1', 'true', 'yes', 'on') $script:RepoPath = $RepoPath $script:ModulePath = $ModulePath $script:ServerName = 'gitbox-mcp' $script:ServerVer = '0.1.0' # Named workflows, mirrored from the orchestrator registry, so the merge gate # can expand a workflow name to its flag string before scanning for m/n/z. $script:Workflows = @{ fork = 'f'; start = 'b'; rename = 'r'; sync = 's'; 'sync-fork' = 'e' commit = 'c'; push = 'u'; pr = 'o'; checks = 'x'; merge = 'm'; release = 'z' revert = 'v'; base = 'g'; checkout = 'k'; unstack = 'n'; stack = 'T' health = 'H'; promote = 'rcuo'; submit = 'cuo'; ship = 'xm'; land = 'cxm' full = 'cuoxm' } # --- stdio plumbing ------------------------------------------------------- # Raw streams with UTF-8 (no BOM) and explicit LF newlines; bypasses the # console's CRLF translation which would otherwise break framing on Windows. $script:Utf8 = New-Object System.Text.UTF8Encoding($false) $script:StdOut = [Console]::OpenStandardOutput() $script:Reader = New-Object System.IO.StreamReader( [Console]::OpenStandardInput(), $script:Utf8) function Write-McpLog([string]$Message) { # Diagnostics go to stderr; stdout is the protocol channel only. [Console]::Error.WriteLine("[$($script:ServerName)] $Message") } function Send-McpMessage($Payload) { $json = $Payload | ConvertTo-Json -Depth 32 -Compress $bytes = $script:Utf8.GetBytes($json + "`n") $script:StdOut.Write($bytes, 0, $bytes.Length) $script:StdOut.Flush() } function Send-McpResult($Id, $Result) { Send-McpMessage ([ordered]@{ jsonrpc = '2.0'; id = $Id; result = $Result }) } function Send-McpError($Id, [int]$Code, [string]$Message) { Send-McpMessage ([ordered]@{ jsonrpc = '2.0'; id = $Id error = [ordered]@{ code = $Code; message = $Message } }) } function New-ToolResult([string]$Text, [bool]$IsError = $false) { [ordered]@{ content = @([ordered]@{ type = 'text'; text = $Text }) isError = $IsError } } # --- merge gate ----------------------------------------------------------- function Resolve-FlagString([string]$Spec) { $s = "$Spec".Trim() if (-not $s) { return '' } if ($script:Workflows.ContainsKey($s)) { return $script:Workflows[$s] } # Longest workflow name wins so 'ship' is matched before any shorter name. foreach ($name in ($script:Workflows.Keys | Sort-Object { $_.Length } -Descending)) { if ($s.StartsWith($name)) { return $script:Workflows[$name] + $s.Substring($name.Length) } } return $s } function Test-MergeGated([string]$Spec) { return (Resolve-FlagString $Spec) -match '[mnz]' } # --- gitbox invocation ---------------------------------------------------- # Child runner: reads a JSON payload from stdin and runs gitbox. Arguments are # transported as data, never as command-line tokens, because native-argument # reconstruction mangles values containing spaces, newlines, or leading dashes # (multiline commit messages, PR bodies). Repo and module paths ride env vars so # the child command string carries no interpolated paths. $script:ChildRunner = @' $ErrorActionPreference = 'Continue' $p = [Console]::In.ReadToEnd() | ConvertFrom-Json Set-Location -LiteralPath $env:GITBOX_MCP_REPO if ($env:GITBOX_MCP_MODULE) { Import-Module $env:GITBOX_MCP_MODULE -Force -ErrorAction Stop } else { Import-Module gitbox -ErrorAction Stop } $call = @($p.flags) + @($p.args) $named = @{} if ($p.body) { $named['Body'] = $p.body } if ($p.allowWip) { $named['AllowWip'] = $true } gitbox @call @named '@ function Invoke-GitboxChild { param( [string]$Flags, [string[]]$PositionalArgs = @(), [string]$Body = '', [bool]$AllowWip = $false ) $payload = [pscustomobject]@{ flags = $Flags args = [object[]]$PositionalArgs body = $Body allowWip = $AllowWip } | ConvertTo-Json -Depth 6 -Compress $prevRepo = $env:GITBOX_MCP_REPO; $prevMod = $env:GITBOX_MCP_MODULE $env:GITBOX_MCP_REPO = $script:RepoPath $env:GITBOX_MCP_MODULE = $script:ModulePath try { $output = $payload | & pwsh -NoProfile -NoLogo -NonInteractive -Command $script:ChildRunner 2>&1 | Out-String $code = if ($null -eq $LASTEXITCODE) { 0 } else { $LASTEXITCODE } } finally { $env:GITBOX_MCP_REPO = $prevRepo; $env:GITBOX_MCP_MODULE = $prevMod } [pscustomobject]@{ Output = $output.TrimEnd(); Code = $code } } # --- tool definitions ----------------------------------------------------- $script:Tools = @( [ordered]@{ name = 'gitbox' description = @' Run a gitbox git-workflow pipeline. `flags` is a stack of single-character flags or a named workflow; `args` are positional values consumed left-to-right by the flags that need one. Examples: flags "b" args ["feat/x"] (create branch); flags "co" args ["fix the thing","Fix the thing"] (commit+push+open PR); flags "Q" (one-line status). A commit message in `args` may be multiline: the first line is the subject and the rest is the commit body. Use `prBody` to give an opened PR a multiline body. Flags execute in a fixed canonical order regardless of input order, and steps whose work is already done are skipped automatically. Merge/release flags (m, n, z) and workflows that include them (ship, land, full, release, unstack, merge) are refused unless the server was started with -AllowMerge. '@ inputSchema = [ordered]@{ type = 'object' properties = [ordered]@{ flags = [ordered]@{ type = 'string' description = 'Flag stack or workflow name, e.g. "co", "b", "start", "submit", "Q".' } args = [ordered]@{ type = 'array' items = [ordered]@{ type = 'string' } description = 'Positional arguments consumed left-to-right by flags that need one.' } allowWip = [ordered]@{ type = 'boolean' description = 'Pass -AllowWip so a commit on an unnamed wip/ branch proceeds without prompting.' } prBody = [ordered]@{ type = 'string' description = 'Body for a PR opened by the o flag. May be multiline markdown. Ignored when no PR is opened.' } } required = @('flags') } }, [ordered]@{ name = 'gitbox_status' description = 'Read-only repository state: one-line status plus the gitbox state hash and the recommended next action. Never mutates.' inputSchema = [ordered]@{ type = 'object'; properties = [ordered]@{} } } ) # --- request handlers ----------------------------------------------------- function Invoke-ToolCall($Params) { $name = $Params.name $a = $Params.arguments switch ($name) { 'gitbox' { $flags = "$($a.flags)".Trim() if (-not $flags) { return (New-ToolResult 'error: "flags" is required' $true) } if (-not $script:AllowMerge -and (Test-MergeGated $flags)) { return (New-ToolResult ("refused: merge/release operations (m, n, z) are disabled. " + "Restart the server with -AllowMerge or set GITBOX_MCP_ALLOW_MERGE=1 to enable.") $true) } $pos = @(); if ($a.args) { $pos = @($a.args | ForEach-Object { "$_" }) } $r = Invoke-GitboxChild -Flags $flags -PositionalArgs $pos ` -Body "$($a.prBody)" -AllowWip ([bool]$a.allowWip) $text = if ($r.Output) { $r.Output } else { "(no output; exit $($r.Code))" } return (New-ToolResult $text ($r.Code -ne 0)) } 'gitbox_status' { $r = Invoke-GitboxChild -Flags 'QS' # Q then S, both read-only diagnostics return (New-ToolResult $r.Output ($r.Code -ne 0)) } default { return (New-ToolResult "unknown tool: $name" $true) } } } function Handle-Message($Msg) { $method = $Msg.method $id = $Msg.id switch ($method) { 'initialize' { $clientVer = "$($Msg.params.protocolVersion)" $pv = if ($clientVer) { $clientVer } else { '2024-11-05' } $mergeState = if ($script:AllowMerge) { 'ENABLED' } else { 'disabled' } Send-McpResult $id ([ordered]@{ protocolVersion = $pv capabilities = [ordered]@{ tools = [ordered]@{} } serverInfo = [ordered]@{ name = $script:ServerName; version = $script:ServerVer } instructions = ("gitbox MCP server. Use gitbox_status to read repo state, " + "then call gitbox with a flag stack or workflow name. " + "Merges/releases are $mergeState.") }) } 'notifications/initialized' { } # notification: no response 'notifications/cancelled' { } 'ping' { Send-McpResult $id ([ordered]@{}) } 'tools/list' { Send-McpResult $id ([ordered]@{ tools = $script:Tools }) } 'tools/call' { Send-McpResult $id (Invoke-ToolCall $Msg.params) } 'prompts/list' { Send-McpResult $id ([ordered]@{ prompts = @() }) } 'resources/list' { Send-McpResult $id ([ordered]@{ resources = @() }) } 'resources/templates/list' { Send-McpResult $id ([ordered]@{ resourceTemplates = @() }) } default { if ($null -ne $id) { Send-McpError $id -32601 "Method not found: $method" } } } } # --- main loop ------------------------------------------------------------ Write-McpLog ("ready | repo=$($script:RepoPath) | merges=" + $(if ($script:AllowMerge) { 'enabled' } else { 'disabled' }) + $(if ($script:ModulePath) { " | module=$($script:ModulePath)" } else { '' })) while ($null -ne ($line = $script:Reader.ReadLine())) { $line = $line.Trim() if (-not $line) { continue } try { $msg = $line | ConvertFrom-Json } catch { Write-McpLog "parse error: $($_.Exception.Message)" continue } try { Handle-Message $msg } catch { Write-McpLog "handler error: $($_.Exception.Message)" if ($null -ne $msg.id) { Send-McpError $msg.id -32603 "Internal error: $($_.Exception.Message)" } } } Write-McpLog 'stdin closed; exiting' |