mcp/Start-SqlCertAuditMcp.ps1

# =============================================================================
# Script : Start-SqlCertAuditMcp.ps1
# Author : Keith Ramsey
# Created : 2026-06-30
# =============================================================================
# MVP Model Context Protocol (MCP) server for the SqlCertForge.Audit (read-only)
# module. Zero-dependency: implements MCP stdio transport (newline-delimited
# JSON-RPC 2.0) by hand -- initialize / tools/list / tools/call / ping.
#
# Exposes ONLY the 4 read-only audit tools. No state-changing cmdlets. This is
# the free "funnel" tier; the paid/state-changing surface is the future C#-hosted
# MCP with a compiled license (see BusinessCreation MCP plan).
#
# Protocol rules honored:
# - stdout carries ONLY MCP JSON messages, one per line, no embedded newlines.
# - stderr is used for any diagnostics.
# =============================================================================
[CmdletBinding()]
param()

$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
[Console]::InputEncoding  = [System.Text.UTF8Encoding]::new($false)

# --- load the read-only module (keep stdout clean: route any noise to stderr) ---
try {
    Import-Module (Join-Path $PSScriptRoot '..\SqlCertForge.Audit.psd1') -Force -ErrorAction Stop 3>$null 4>$null 5>$null 6>$null
}
catch {
    [Console]::Error.WriteLine("FATAL: could not load SqlCertForge.Audit: $($_.Exception.Message)")
    exit 1
}

# --- tool catalogue: MCP tool name -> PowerShell function + input schema ----
$script:Tools = @(
    @{
        name        = 'get_rs_http_config'
        fn          = 'Get-RsHttpConfig'
        description = 'Inventory a Reporting Services instance HTTPS surface (read-only): reserved URLs, SSL certificate bindings, and the actual registered/browseable URLs. Makes no changes.'
        inputSchema = @{
            type       = 'object'
            properties = @{
                RsInstance   = @{ type = 'string'; description = "RS instance: 'SSRS', 'PBIRS', or the SQL instance name."; default = 'MSSQLSERVER' }
                ComputerName = @{ type = 'string'; description = 'Target computer. Defaults to the local machine.' }
            }
        }
    },
    @{
        name        = 'test_rs_https_endpoint'
        fn          = 'Test-RsHttpsEndpoint'
        description = 'Confirm a Reporting Services endpoint actually SERVES over HTTPS (read-only): discovers the URL, issues a GET (200/401 = healthy), and validates the served certificate SAN/thumbprint. For wildcard reservations, pass VanityHost.'
        inputSchema = @{
            type       = 'object'
            properties = @{
                RsInstance         = @{ type = 'string'; default = 'MSSQLSERVER' }
                Application        = @{ type = 'string'; enum = @('ReportServerWebService', 'ReportServerWebApp'); default = 'ReportServerWebService' }
                VanityHost         = @{ type = 'string'; description = 'The host name users actually type (required when the reservation is a strong wildcard).' }
                Url                = @{ type = 'string'; description = 'Explicit full URL to test (overrides discovery).' }
                ExpectedThumbprint = @{ type = 'string'; description = '40-hex thumbprint the served cert must match.' }
                ComputerName       = @{ type = 'string' }
            }
        }
    },
    @{
        name        = 'test_sql_cert_binding'
        fn          = 'Test-SqlCertBinding'
        description = 'Report the TLS certificate binding for one or more SQL Server instances from the registry (read-only): thumbprint, ForceEncryption, and expiry. Supports remote -Node.'
        inputSchema = @{
            type       = 'object'
            properties = @{
                SqlInstance = @{ type = 'array'; items = @{ type = 'string' }; description = "Instance(s): 'MSSQLSERVER' or 'HOST\\INSTANCE'." }
                Node        = @{ type = 'array'; items = @{ type = 'string' }; description = 'Computer(s) to read. Defaults to local.' }
            }
            required   = @('SqlInstance')
        }
    },
    @{
        name        = 'test_rs_cert_binding'
        fn          = 'Test-RsCertBinding'
        description = 'Report the TLS certificate bound to a Reporting Services endpoint via WMI (read-only): thumbprint and expiry for the requested application.'
        inputSchema = @{
            type       = 'object'
            properties = @{
                RsInstance   = @{ type = 'string'; default = 'MSSQLSERVER' }
                Application  = @{ type = 'string'; enum = @('ReportServerWebService', 'ReportServerWebApp'); default = 'ReportServerWebService' }
                ComputerName = @{ type = 'string' }
            }
        }
    }
)

function Write-Rpc($obj) {
    # Compact, single-line JSON (string values keep newlines as escaped \n).
    [Console]::Out.WriteLine(($obj | ConvertTo-Json -Depth 25 -Compress))
}

[Console]::Error.WriteLine("sqlcertforge-audit MCP server ready ($(($script:Tools).Count) tools).")

while ($null -ne ($line = [Console]::In.ReadLine())) {
    if ([string]::IsNullOrWhiteSpace($line)) { continue }
    try { $msg = $line | ConvertFrom-Json -ErrorAction Stop }
    catch { [Console]::Error.WriteLine("bad JSON: $($_.Exception.Message)"); continue }

    $id     = if ($msg.PSObject.Properties['id']) { $msg.id } else { $null }
    $method = [string]$msg.method

    switch ($method) {
        'initialize' {
            Write-Rpc @{ jsonrpc = '2.0'; id = $id; result = @{
                    protocolVersion = '2024-11-05'
                    capabilities    = @{ tools = @{} }
                    serverInfo      = @{ name = 'sqlcertforge-audit'; version = '0.1.0' }
                } }
        }
        'notifications/initialized' { }  # notification: no response
        'ping' { Write-Rpc @{ jsonrpc = '2.0'; id = $id; result = @{} } }
        'tools/list' {
            $list = $script:Tools | ForEach-Object { @{ name = $_.name; description = $_.description; inputSchema = $_.inputSchema } }
            Write-Rpc @{ jsonrpc = '2.0'; id = $id; result = @{ tools = @($list) } }
        }
        'tools/call' {
            $name = [string]$msg.params.name
            $tool = $script:Tools | Where-Object { $_.name -eq $name } | Select-Object -First 1
            if (-not $tool) {
                Write-Rpc @{ jsonrpc = '2.0'; id = $id; error = @{ code = -32602; message = "Unknown tool: $name" } }
            }
            else {
                try {
                    $params = @{}
                    if ($msg.params.PSObject.Properties['arguments'] -and $null -ne $msg.params.arguments) {
                        $msg.params.arguments.PSObject.Properties | ForEach-Object { $params[$_.Name] = $_.Value }
                    }
                    $res  = & $tool.fn @params
                    $text = $res | ConvertTo-Json -Depth 25
                    Write-Rpc @{ jsonrpc = '2.0'; id = $id; result = @{ content = @(@{ type = 'text'; text = $text }); isError = $false } }
                }
                catch {
                    Write-Rpc @{ jsonrpc = '2.0'; id = $id; result = @{ content = @(@{ type = 'text'; text = "Error: $($_.Exception.Message)" }); isError = $true } }
                }
            }
        }
        default {
            if ($null -ne $id) {
                Write-Rpc @{ jsonrpc = '2.0'; id = $id; error = @{ code = -32601; message = "Method not found: $method" } }
            }
        }
    }
}