Table of Contents

scripts/_mcpClient.ps1

This page is part of the documentation for Orleans.Lattice 9.9.0 (release line 9.9), built 2026-10-04. It is also published as markdown, with every table and list, at scripts-mcpclient-ps1.md, and llms.txt lists every page.

Part of RepoContextContainer source.

<#
    .SYNOPSIS
        Minimal MCP streamable-HTTP client shared by the container probes.

    .DESCRIPTION
        The repocontext container's only application listener is the MCP
        endpoint; there is no REST route to any of this. The reference "mcp" CLI
        is deliberately NOT used: a harness that silently no-ops when a CLI is
        absent reproduces, one level up, the exact defect a probe exists to
        delete.

        This file is dot-sourced, following the `_`-prefixed convention already
        used by _provenance.ps1, _deployManifest.ps1 and _tuningKnobs.ps1. It was
        extracted from Invoke-AnnQueryProbe.ps1 when a second probe needed the
        same client: a copy would have drifted, and the usual direction of drift
        is the copy quietly proving less than the original while looking
        identical at the call site.

        Call Set-McpEndpoint once before any other function here.

    .EXAMPLE
        . "$PSScriptRoot/_mcpClient.ps1"
        Set-McpEndpoint -BaseUri 'http://localhost:8080' -TimeoutSeconds 240 -ClientName 'my-probe'
        Initialize-McpSession | Out-Null
        $outcome = Invoke-McpTool -Name 'repocontext_health' -Arguments @{ repoId = 'lattice' }
#>

$script:McpSessionId = $null
$script:NextRequestId = 1
$script:McpBaseUri = $null
$script:McpTimeoutSeconds = 240
$script:McpClientName = 'repocontext-probe'

<#
    Points the client at one endpoint and resets any session already in force.

    The reset is not incidental: a session id belongs to the endpoint that
    issued it, so carrying one across a re-point would send the previous box's
    session to a different box, which fails in a way that reads as a protocol
    fault rather than as a harness mistake.
#>
function Set-McpEndpoint {
    param(
        [Parameter(Mandatory)][string] $BaseUri,
        [int] $TimeoutSeconds = 240,
        [string] $ClientName = 'repocontext-probe'
    )

    $script:McpBaseUri = $BaseUri.TrimEnd('/')
    $script:McpTimeoutSeconds = $TimeoutSeconds
    $script:McpClientName = $ClientName
    $script:McpSessionId = $null
    $script:NextRequestId = 1
}

<#
    Posts one JSON-RPC message and returns the parsed result. A streamable-HTTP
    MCP endpoint may answer either as application/json or as an SSE stream, so
    both framings are handled; an SSE body has its "data:" payloads extracted.
#>
function Invoke-McpRpc {
    param(
        [string] $Method,
        [hashtable] $Parameters,
        [switch] $IsNotification
    )

    if (-not $script:McpBaseUri) {
        throw 'Call Set-McpEndpoint before using the MCP client.'
    }

    $body = @{ jsonrpc = '2.0'; method = $Method }
    if ($Parameters) { $body['params'] = $Parameters }
    if (-not $IsNotification) {
        $body['id'] = $script:NextRequestId
        $script:NextRequestId++
    }

    $headers = @{
        'Accept'       = 'application/json, text/event-stream'
        'Content-Type' = 'application/json'
    }
    if ($script:McpSessionId) {
        $headers['Mcp-Session-Id'] = $script:McpSessionId
    }

    $json = $body | ConvertTo-Json -Depth 12 -Compress

    $response = Invoke-WebRequest -Uri $script:McpBaseUri -Method Post -Headers $headers `
        -Body $json -TimeoutSec $script:McpTimeoutSeconds -UseBasicParsing

    # The server assigns a session on initialize and expects it echoed back.
    $assigned = $response.Headers['Mcp-Session-Id']
    if ($assigned) {
        if ($assigned -is [array]) { $assigned = $assigned[0] }
        if ($assigned) { $script:McpSessionId = $assigned }
    }

    if ($IsNotification) { return $null }

    $content = $response.Content
    if (-not $content) { return $null }

    # SSE framing: pull the data payloads out and take the last complete one.
    if ($content -match '(?m)^\s*(event|data):') {
        $payloads = @()
        foreach ($line in ($content -split "`n")) {
            if ($line -match '^\s*data:\s?(?<payload>.*)$') {
                $candidate = $Matches['payload'].Trim()
                if ($candidate.Length -gt 0 -and $candidate -ne '[DONE]') {
                    $payloads += $candidate
                }
            }
        }
        if ($payloads.Count -eq 0) { return $null }
        $content = $payloads[-1]
    }

    return $content | ConvertFrom-Json
}

function Initialize-McpSession {
    $init = Invoke-McpRpc -Method 'initialize' -Parameters @{
        protocolVersion = '2024-11-05'
        capabilities    = @{}
        clientInfo      = @{ name = $script:McpClientName; version = '1.0' }
    }

    if (-not $init) {
        throw 'The MCP initialize call returned no parsed result.'
    }
    if ($init.PSObject.Properties.Name -contains 'error' -and $init.error) {
        throw "The MCP initialize call failed: $($init.error | ConvertTo-Json -Depth 6 -Compress)"
    }

    Invoke-McpRpc -Method 'notifications/initialized' -Parameters @{} -IsNotification | Out-Null
    return $init
}

<#
    Calls one MCP tool and returns a normalised outcome. A JSON-RPC error, a
    tool-level isError, and an unparseable body are all reported as failures
    with the reason preserved, because "issued but rejected" must never be
    silently counted as a success.
#>
function Invoke-McpTool {
    param([string] $Name, [hashtable] $Arguments)

    $outcome = [ordered]@{
        Tool      = $Name
        Succeeded = $false
        Reason    = $null
        Payload   = $null
    }

    try {
        $response = Invoke-McpRpc -Method 'tools/call' -Parameters @{
            name      = $Name
            arguments = $Arguments
        }
    }
    catch {
        $outcome.Reason = "transport: $($_.Exception.Message)"
        return $outcome
    }

    if (-not $response) {
        $outcome.Reason = 'empty response body'
        return $outcome
    }
    if ($response.PSObject.Properties.Name -contains 'error' -and $response.error) {
        $outcome.Reason = "jsonrpc error: $($response.error | ConvertTo-Json -Depth 6 -Compress)"
        return $outcome
    }
    if (-not ($response.PSObject.Properties.Name -contains 'result')) {
        $outcome.Reason = 'response carried no result member'
        return $outcome
    }

    $toolResult = $response.result
    if ($toolResult.PSObject.Properties.Name -contains 'isError' -and $toolResult.isError) {
        $outcome.Reason = 'tool reported isError'
        $outcome.Payload = $toolResult
        return $outcome
    }

    # The tool payload is carried as structuredContent when present, otherwise
    # as JSON text in the first content block.
    $payload = $null
    if ($toolResult.PSObject.Properties.Name -contains 'structuredContent' -and $toolResult.structuredContent) {
        $payload = $toolResult.structuredContent
    }
    elseif ($toolResult.PSObject.Properties.Name -contains 'content' -and $toolResult.content) {
        foreach ($block in $toolResult.content) {
            if ($block.PSObject.Properties.Name -contains 'text' -and $block.text) {
                try { $payload = $block.text | ConvertFrom-Json; break } catch { $payload = $block.text }
            }
        }
    }

    $outcome.Succeeded = $true
    $outcome.Payload = $payload
    return $outcome
}