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'