Files
mcp-microsoft/eng/scripts/Measure-McpOutputSizes.ps1
8ab9c1e260 Add MCP output size measurement tooling (#3288)
* Add MCP output size measurement tooling

Adds an opt-in stdio integration test (McpOutputSizeTests) that measures
the MCP server's initialize greeting, tools/list discovery response, and
per-tool learn-mode responses in both consolidated and namespace server
modes, including tool counts and inner-command schema verification.

Adds Summarize-McpOutputSizes.ps1 to produce console, JSON, and Markdown
summaries comparing the two modes, extract readable top learn-response
text, split inner commands into per-tool files, and report every learn
response over a configurable UTF-8 byte threshold (default 45000) in
addition to the top 10 largest.

Adds Measure-McpOutputSizes.ps1 to chain build, test, and summarize into
a single command, with comment-based help and CmdletBinding support on
both scripts.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Address MCP output review comments

Co-authored-by: LarryOsterman <8220172+LarryOsterman@users.noreply.github.com>

* Dispose command learn JSON documents

Co-authored-by: LarryOsterman <8220172+LarryOsterman@users.noreply.github.com>

* Convert McpOutputSizeTests to standalone McpOutputSizeMeasurer tool

Extract the raw JSON-RPC/stdio output-size measurement logic out of the
xunit test McpOutputSizeTests.cs into a new standalone console app,
eng/tools/McpOutputSizeMeasurer, following existing eng/tools/* conventions
(Exe project, AOT-compatible where possible, InternalsVisibleTo test
project, reuses McpToolEvaluator.Core.Utilities.FindRepoRoot).

- New eng/tools/McpOutputSizeMeasurer/src: ported MeasureAsync/MeasureModeAsync,
  raw stdio JSON-RPC handshake/discovery/learn measurement, with
  Assert-based validation replaced by thrown exceptions since it's no
  longer test code. Disabled AOT-compat (anonymous-type JSON serialization
  is incompatible with trimming analysis for this dev/CI-only tool).
- New Program.cs CLI: --executable/--report/--mode/--verbose/--help,
  defaulting to the built azmcp binary and TestResults/mcp-output-size.json.
- New McpOutputSizeMeasurer.Tests: 3 unit tests for GetInnerCommandNames.
- Registered both projects in Microsoft.Mcp.slnx and eng/tools/Tools.sln.
- Updated Measure-McpOutputSizes.ps1 to build/run the new standalone tool
  directly instead of the (now removed) xunit test via env vars.
- Removed servers/Azure.Mcp.Server/tests/.../Infrastructure/McpOutputSizeTests.cs
  entirely; its functionality is fully absorbed by the new tool.
- Minor doc comment updates in Summarize-McpOutputSizes.ps1 / the new tool
  referencing the old test name.

Validated: dotnet build for the new src/tests projects succeeds; the 3 new
unit tests pass; Azure.Mcp.Server.Tests still builds after the file removal;
ran the new McpOutputSizeMeasurer.exe end-to-end against the built azmcp.exe
and fed the report into Summarize-McpOutputSizes.ps1, confirming console/JSON
output (including the >45000-byte threshold list) is produced correctly.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Add -ServerExecutable param to Measure-McpOutputSizes.ps1

Allows measuring a pre-built or published azmcp binary (e.g. extracted
from a GitHub release) instead of always building the local
servers/Azure.Mcp.Server/src project. When -ServerExecutable is supplied,
the local server build step is skipped (the measurer tool is still built
unless -SkipBuild is also passed), enabling side-by-side comparisons of
current-source vs. a previously released server by running the script
twice with different -OutputDirectory values.

Validated by running the script against the locally built azmcp.exe as a
stand-in released binary, both with and without -SkipBuild, confirming the
measurement/summarize pipeline completes and produces correct output.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Default to .work output dir; add -ReleaseTag GitHub download support

Measure-McpOutputSizes.ps1 changes:
- Default -OutputDirectory moved from <repo-root>/TestResults to the
  repo-standard <repo-root>/.work/mcp-output-size (already gitignored via
  .work/, consistent with Pack-Zip.ps1/VallyEvaluator/ToolMetadataExporter
  conventions).
- New -ReleaseTag parameter: given a microsoft/mcp GitHub release tag (e.g.
  Azure.Mcp.Server-3.0.0-beta.36), downloads the matching platform asset
  (Azure.Mcp.Server-<os>-<arch>.zip, matching Pack-Zip.ps1's naming),
  extracts it under <OutputDirectory>/release-download/<tag>, and measures
  the extracted azmcp binary instead of building the local source tree.
  Mutually exclusive with -ServerExecutable.
- New -GitHubRepository parameter (defaults to microsoft/mcp) to resolve
  the release download URL, for forks/mirrors.

This lets a previously released server version be measured without it
being present locally, so its output sizes can be diffed against the
current source tree by running the script twice with different
-OutputDirectory values.

Validated end-to-end: downloaded and measured the win-arm64 asset for
Azure.Mcp.Server-3.0.0-beta.36, confirmed the report and console/JSON/
Markdown summaries were produced correctly under the new default
.work/mcp-output-size location.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Merge MCP output size summarization into measurement script

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Remove obsolete summarizer references

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Report MCP tool limit without failing measurement

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Handle timeouts better

* Measure decoded MCP learn payloads

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Fix output size measurement review issues

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Use MCP SDK for output measurement

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: LarryOsterman <8220172+LarryOsterman@users.noreply.github.com>
2026-08-25 16:09:34 -07:00

662 lines
27 KiB
PowerShell

#!/bin/env pwsh
#Requires -Version 7
<#
.SYNOPSIS
Builds and runs the MCP output size measurement test, then summarizes the results.
.DESCRIPTION
Runs the full measurement workflow end to end:
1. Builds the Azure.Mcp.Server project and the standalone McpOutputSizeMeasurer tool.
2. Runs McpOutputSizeMeasurer, which starts the MCP server over stdio in both
consolidated and namespace modes and measures the negotiated server metadata,
tools/list discovery, and decoded learn-mode command JSON (including inner
commands) for every tool.
3. Summarizes the report to produce console, JSON, and Markdown summaries,
extract readable description text, and split each top tool's inner commands
into individual files.
.PARAMETER OutputDirectory
Directory for the measurement report and its artifacts.
Defaults to `<repo-root>/.work/mcp-output-size`.
.PARAMETER Configuration
The build configuration to use. Defaults to `Debug`.
.PARAMETER SkipBuild
Skip the build step and use the existing binaries. Implied (and not required) when
-ServerExecutable or -ReleaseTag is supplied, since that server binary isn't built by
this script.
.PARAMETER Clean
Remove the output directory before running so stale artifacts are not mixed with
the new results.
.PARAMETER LearnResponseThresholdUtf8Bytes
Include every learn response over this UTF-8 byte threshold in the summary.
Defaults to 28000.
.PARAMETER ServerExecutable
Path to an already-built or published azmcp server executable to measure, instead of
building and using the local servers/Azure.Mcp.Server/src project. Use this to measure
a previously released version of the server (e.g. a binary extracted from a release
asset or installed via a package manager) so its output sizes can be diffed against
the current source tree. When supplied, the local server project is not built or
resolved; only the McpOutputSizeMeasurer tool is still built (unless -SkipBuild is
also passed). Mutually exclusive with -ReleaseTag.
.PARAMETER ReleaseTag
A GitHub release tag from the microsoft/mcp repository (e.g.
`Azure.Mcp.Server-3.0.0-beta.36`) whose azmcp server asset should be downloaded and
measured, instead of building the local source tree. The matching platform zip
(`Azure.Mcp.Server-<os>-<arch>.zip`) is downloaded to
`<OutputDirectory>/release-download/<ReleaseTag>` and extracted before measurement.
Mutually exclusive with -ServerExecutable.
.PARAMETER GitHubRepository
The `owner/repo` used to resolve -ReleaseTag. Defaults to `microsoft/mcp`. Only used
when -ReleaseTag is supplied.
.EXAMPLE
./eng/scripts/Measure-McpOutputSizes.ps1
Builds, measures, and summarizes using default paths.
.EXAMPLE
./eng/scripts/Measure-McpOutputSizes.ps1 -SkipBuild -Clean
Reuses the existing build, clears previous results, then measures and summarizes.
.EXAMPLE
./eng/scripts/Measure-McpOutputSizes.ps1 -ServerExecutable C:\releases\azmcp-1.2.3\azmcp.exe -OutputDirectory .work/mcp-output-size/released-1.2.3
Measures a previously released azmcp build (e.g. downloaded/extracted from a GitHub
release) instead of the local source tree, writing results to a separate directory so
they can be compared against a current-source run.
.EXAMPLE
./eng/scripts/Measure-McpOutputSizes.ps1 -ReleaseTag Azure.Mcp.Server-3.0.0-beta.36 -OutputDirectory .work/mcp-output-size/beta.36
Downloads the azmcp server asset for the given release tag from GitHub, measures it,
and writes results to a separate directory so they can be compared against a
current-source run.
#>
[CmdletBinding()]
param(
[string]$OutputDirectory,
[string]$Configuration = 'Debug',
[switch]$SkipBuild,
[switch]$Clean,
[int]$LearnResponseThresholdUtf8Bytes = 28000,
[string]$ServerExecutable,
[string]$ReleaseTag,
[string]$GitHubRepository = 'microsoft/mcp'
)
if ($ServerExecutable -and $ReleaseTag) {
Write-Error "-ServerExecutable and -ReleaseTag are mutually exclusive."
exit 1
}
$ErrorActionPreference = 'Stop'
$maximumToolsPerClientRequest = 128
. "$PSScriptRoot/../common/scripts/common.ps1"
$repoRoot = $RepoRoot.Path
$serverProject = Join-Path $repoRoot 'servers/Azure.Mcp.Server/src'
$measurerProject = Join-Path $repoRoot 'eng/tools/McpOutputSizeMeasurer/src'
$measurerBuildInfoPath = Join-Path $repoRoot 'eng/tools/McpOutputSizeMeasurer/build-info.json'
$measurerBuildOutput = Join-Path $repoRoot '.work/mcp-output-size-measurer'
$serverBuildOutput = Join-Path $repoRoot '.work/mcp-output-size-server'
$measurerArchitecture = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant()
$measurerOperatingSystem = if ($IsWindows) { 'windows' } elseif ($IsLinux) { 'linux' } else { 'macos' }
$measurerDotnetOs = if ($IsWindows) { 'win' } elseif ($IsLinux) { 'linux' } else { 'osx' }
$measurerPlatform = "$measurerOperatingSystem-$measurerArchitecture"
$measurerRuntime = "$measurerDotnetOs-$measurerArchitecture"
$measurerExecutable = Join-Path $measurerBuildOutput "$measurerRuntime/McpOutputSizeMeasurer$(if ($IsWindows) { '.exe' } else { '' })"
$localServerExecutable = Join-Path $serverBuildOutput "Azure.Mcp.Server/$measurerPlatform/azmcp$(if ($IsWindows) { '.exe' } else { '' })"
function Get-PercentDifference([double] $value, [double] $baseline) {
if ($baseline -eq 0) {
return $null
}
return [math]::Round((($value - $baseline) / $baseline) * 100, 2)
}
function Get-ModeSummary($mode) {
$learnCount = @(
$mode.learnResponses |
Where-Object { $_.sizeBasis -eq 'decodedCommandJson' }
).Count
$discoveryCount = @($mode.discoveryResponses).Count
$averageLearnBytes = if ($learnCount -eq 0) { 0 } else {
[math]::Round($mode.learnTotalUtf8Bytes / $learnCount, 2)
}
return [ordered]@{
mode = $mode.mode
toolCount = $mode.toolCount
serverMetadataUtf8Bytes = $mode.initialServerMetadata.utf8Bytes
discoveryMessageCount = $discoveryCount
discoveryUtf8Bytes = $mode.discoveryTotalUtf8Bytes
learnMessageCount = $learnCount
learnResponsesWithoutCommandJson = $mode.learnResponsesWithoutCommandJson
learnUtf8Bytes = $mode.learnTotalUtf8Bytes
averageLearnUtf8Bytes = $averageLearnBytes
totalMeasuredPayloadUtf8Bytes = $mode.totalMeasuredPayloadUtf8Bytes
}
}
function Save-LearnResponseText($entries) {
foreach ($entry in $entries) {
if (!$entry.learnResponseFile -or !(Test-Path -LiteralPath $entry.learnResponseFile -PathType Leaf)) {
continue
}
$learnJson = Get-Content -LiteralPath $entry.learnResponseFile -Raw | ConvertFrom-Json
$textParts = @(
$learnJson.content |
Where-Object { $_.type -eq 'text' -and $null -ne $_.text } |
ForEach-Object { $_.text }
)
if ($textParts.Count -eq 0) {
continue
}
$textPath = [IO.Path]::ChangeExtension($entry.learnResponseFile, '.txt')
Set-Content -LiteralPath $textPath -Value ($textParts -join "`r`n`r`n") -Encoding utf8NoBOM -NoNewline
$entry | Add-Member -NotePropertyName learnResponseTextFile -NotePropertyValue $textPath -Force
}
}
function Save-InnerCommands($entries) {
foreach ($entry in $entries) {
$entry | Add-Member -NotePropertyName innerCommandCount -NotePropertyValue 0 -Force
$entry | Add-Member -NotePropertyName innerCommandDirectory -NotePropertyValue $null -Force
if (!$entry.learnResponseTextFile -or !(Test-Path -LiteralPath $entry.learnResponseTextFile -PathType Leaf)) {
continue
}
$text = Get-Content -LiteralPath $entry.learnResponseTextFile -Raw
$start = $text.IndexOf('[')
if ($start -lt 0) {
continue
}
try {
$commands = @($text.Substring($start) | ConvertFrom-Json)
} catch {
Write-Warning "Could not parse inner commands for '$($entry.tool)': $_"
continue
}
if ($commands.Count -eq 0) {
continue
}
$toolDirectory = [IO.Path]::Combine(
[IO.Path]::GetDirectoryName($entry.learnResponseTextFile),
[IO.Path]::GetFileNameWithoutExtension($entry.learnResponseTextFile) + '-commands')
New-Item -ItemType Directory -Path $toolDirectory -Force | Out-Null
foreach ($command in $commands) {
$commandName = if ($command.command) { $command.command } else { 'unnamed' }
foreach ($invalid in [IO.Path]::GetInvalidFileNameChars()) {
$commandName = $commandName.Replace($invalid, '-')
}
$commandPath = [IO.Path]::Combine($toolDirectory, "$commandName.json")
Set-Content -LiteralPath $commandPath -Value ($command | ConvertTo-Json -Depth 30) -Encoding utf8NoBOM
}
$entry.innerCommandCount = $commands.Count
$entry.innerCommandDirectory = $toolDirectory
}
}
function Get-TopLearnResponses($entries) {
return @($entries) |
Where-Object { $_.sizeBasis -eq 'decodedCommandJson' } |
Sort-Object -Property utf8Bytes -Descending |
Select-Object -First 10 |
ForEach-Object {
[ordered]@{
tool = $_.tool
utf8Bytes = $_.utf8Bytes
characterCount = $_.characterCount
sizeBasis = $_.sizeBasis
learnResponseFile = $_.learnResponseFile
learnResponseTextFile = $_.learnResponseTextFile
innerCommandCount = $_.innerCommandCount
innerCommandDirectory = $_.innerCommandDirectory
}
}
}
function Get-LargeLearnResponses($entries) {
return @($entries) |
Where-Object {
$_.sizeBasis -eq 'decodedCommandJson' -and
$_.utf8Bytes -gt $LearnResponseThresholdUtf8Bytes
} |
Sort-Object -Property utf8Bytes -Descending |
ForEach-Object {
[ordered]@{
tool = $_.tool
utf8Bytes = $_.utf8Bytes
characterCount = $_.characterCount
sizeBasis = $_.sizeBasis
learnResponseFile = $_.learnResponseFile
learnResponseTextFile = $_.learnResponseTextFile
innerCommandCount = $_.innerCommandCount
innerCommandDirectory = $_.innerCommandDirectory
}
}
}
function Invoke-McpOutputSizeSummary {
param(
[Parameter(Mandatory)]
[string] $InputPath,
[string] $OutputPath,
[string] $MarkdownPath,
[int] $LearnResponseThresholdUtf8Bytes = 28000
)
if (!(Test-Path -LiteralPath $InputPath -PathType Leaf)) {
throw "Measurement report not found: $InputPath"
}
$report = Get-Content -LiteralPath $InputPath -Raw | ConvertFrom-Json
$modeResults = @{}
foreach ($mode in $report.modes) {
$modeResults[$mode.mode] = $mode
}
foreach ($modeName in @('consolidated', 'namespace')) {
if (!$modeResults.ContainsKey($modeName)) {
throw "The report does not contain the '$modeName' mode."
}
}
$consolidated = Get-ModeSummary $modeResults['consolidated']
$namespace = Get-ModeSummary $modeResults['namespace']
$comparisonMetrics = @(
'toolCount',
'serverMetadataUtf8Bytes',
'discoveryMessageCount',
'discoveryUtf8Bytes',
'learnMessageCount',
'learnResponsesWithoutCommandJson',
'learnUtf8Bytes',
'averageLearnUtf8Bytes',
'totalMeasuredPayloadUtf8Bytes'
)
$comparison = [ordered]@{}
foreach ($metric in $comparisonMetrics) {
$consolidatedValue = [double]$consolidated[$metric]
$namespaceValue = [double]$namespace[$metric]
$comparison[$metric] = [ordered]@{
consolidated = $consolidatedValue
namespace = $namespaceValue
difference = $consolidatedValue - $namespaceValue
percentDifferenceFromNamespace = Get-PercentDifference $consolidatedValue $namespaceValue
}
}
$allEntriesByMode = [ordered]@{
consolidated = @($modeResults['consolidated'].learnResponses)
namespace = @($modeResults['namespace'].learnResponses)
}
foreach ($modeName in @('consolidated', 'namespace')) {
foreach ($entry in $allEntriesByMode[$modeName]) {
$entry | Add-Member -NotePropertyName learnResponseTextFile -NotePropertyValue $null -Force
}
Save-LearnResponseText $allEntriesByMode[$modeName]
Save-InnerCommands $allEntriesByMode[$modeName]
}
$topLearnByMode = [ordered]@{
consolidated = Get-TopLearnResponses $allEntriesByMode['consolidated']
namespace = Get-TopLearnResponses $allEntriesByMode['namespace']
}
$largeLearnByMode = [ordered]@{
consolidated = Get-LargeLearnResponses $allEntriesByMode['consolidated']
namespace = Get-LargeLearnResponses $allEntriesByMode['namespace']
}
$summary = [ordered]@{
sourceReport = (Resolve-Path -LiteralPath $InputPath).Path
generatedAtUtc = [DateTimeOffset]::UtcNow
transport = $report.transport
protocolClient = $report.protocolClient
learnSizeBasis = $report.learnSizeBasis
learnResponseThresholdUtf8Bytes = $LearnResponseThresholdUtf8Bytes
modes = @($consolidated, $namespace)
comparison = $comparison
topLearnResponses = $topLearnByMode
largeLearnResponses = $largeLearnByMode
}
Write-Host "MCP output size summary ($($report.transport); learn sizes use decoded command JSON)"
Write-Host ""
$consoleRows = @($consolidated, $namespace) | ForEach-Object {
[pscustomobject]@{
mode = $_['mode']
toolCount = $_['toolCount']
serverMetadataUtf8Bytes = $_['serverMetadataUtf8Bytes']
discoveryUtf8Bytes = $_['discoveryUtf8Bytes']
learnUtf8Bytes = $_['learnUtf8Bytes']
totalMeasuredPayloadUtf8Bytes = $_['totalMeasuredPayloadUtf8Bytes']
}
}
$consoleRows |
Format-Table mode, toolCount, serverMetadataUtf8Bytes, discoveryUtf8Bytes, learnUtf8Bytes, totalMeasuredPayloadUtf8Bytes |
Out-Host
Write-Host "Consolidated relative to namespace:"
foreach ($metric in $comparisonMetrics) {
$result = $comparison[$metric]
$percent = if ($null -eq $result.percentDifferenceFromNamespace) {
'n/a'
} else {
"$($result.percentDifferenceFromNamespace)%"
}
Write-Host (" {0}: difference {1}, {2}" -f $metric, $result.difference, $percent)
}
foreach ($modeName in @('consolidated', 'namespace')) {
Write-Host ""
Write-Host "Top 10 largest decoded learn command arrays ($modeName):"
$topLearnByMode[$modeName] | ForEach-Object {
[pscustomobject]@{
tool = $_.tool
utf8Bytes = $_.utf8Bytes
innerCommands = $_.innerCommandCount
}
} | Format-Table tool, utf8Bytes, innerCommands | Out-Host
Write-Host "Decoded learn command arrays over $LearnResponseThresholdUtf8Bytes UTF-8 bytes ($modeName):"
$largeLearnByMode[$modeName] | ForEach-Object {
[pscustomobject]@{
tool = $_.tool
utf8Bytes = $_.utf8Bytes
}
} | Format-Table tool, utf8Bytes | Out-Host
}
$summaryJson = $summary | ConvertTo-Json -Depth 10
if (![string]::IsNullOrWhiteSpace($OutputPath)) {
$resolvedOutputPath = [IO.Path]::GetFullPath($OutputPath)
$outputDirectory = [IO.Path]::GetDirectoryName($resolvedOutputPath)
if (![string]::IsNullOrWhiteSpace($outputDirectory)) {
New-Item -ItemType Directory -Path $outputDirectory -Force | Out-Null
}
Set-Content -LiteralPath $resolvedOutputPath -Value $summaryJson -Encoding utf8
Write-Host ""
Write-Host "Summary JSON saved to $resolvedOutputPath"
} else {
Write-Output $summaryJson
}
if ([string]::IsNullOrWhiteSpace($MarkdownPath)) {
$MarkdownPath = [IO.Path]::ChangeExtension(
[IO.Path]::GetFullPath($InputPath),
'.md')
}
$markdownLines = [System.Collections.Generic.List[string]]::new()
$markdownLines.Add('# MCP Output Size Summary')
$markdownLines.Add('')
$markdownLines.Add('- **Source report:** `' + $summary.sourceReport + '`')
$markdownLines.Add("- **Transport:** $($summary.transport)")
$markdownLines.Add("- **Protocol client:** $($summary.protocolClient)")
$markdownLines.Add("- **Learn size basis:** $($summary.learnSizeBasis)")
$markdownLines.Add("- **Generated:** $($summary.generatedAtUtc)")
$markdownLines.Add("- **Large learn response threshold:** $($summary.learnResponseThresholdUtf8Bytes) UTF-8 bytes")
$markdownLines.Add('')
$markdownLines.Add('## Mode Summary')
$markdownLines.Add('')
$markdownLines.Add('| Mode | Tools | Learn command arrays | Learn responses without command JSON | Server metadata (bytes) | Discovery payloads (bytes) | Learn decoded (bytes) | Total measured payloads (bytes) |')
$markdownLines.Add('| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: |')
foreach ($mode in $summary.modes) {
$markdownLines.Add(
"| $($mode.mode) | $($mode.toolCount) | $($mode.learnMessageCount) | " +
"$($mode.learnResponsesWithoutCommandJson) | $($mode.serverMetadataUtf8Bytes) | " +
"$($mode.discoveryUtf8Bytes) | $($mode.learnUtf8Bytes) | $($mode.totalMeasuredPayloadUtf8Bytes) |")
}
$markdownLines.Add('')
$markdownLines.Add('## Consolidated vs. Namespace')
$markdownLines.Add('')
$markdownLines.Add('| Metric | Consolidated | Namespace | Difference | Difference vs. namespace |')
$markdownLines.Add('| --- | ---: | ---: | ---: | ---: |')
foreach ($metric in $comparisonMetrics) {
$result = $comparison[$metric]
$percent = if ($null -eq $result.percentDifferenceFromNamespace) {
'n/a'
} else {
"$($result.percentDifferenceFromNamespace)%"
}
$markdownLines.Add(
"| $metric | $($result.consolidated) | $($result.namespace) | " +
"$($result.difference) | $percent |")
}
$resolvedMarkdownPath = [IO.Path]::GetFullPath($MarkdownPath)
$markdownDirectory = [IO.Path]::GetDirectoryName($resolvedMarkdownPath)
if (![string]::IsNullOrWhiteSpace($markdownDirectory)) {
New-Item -ItemType Directory -Path $markdownDirectory -Force | Out-Null
}
foreach ($modeName in @('consolidated', 'namespace')) {
$markdownLines.Add('')
$markdownLines.Add("## Top 10 Largest Decoded Learn Command Arrays ($modeName)")
$markdownLines.Add('')
$markdownLines.Add('| Rank | Tool | Bytes | Inner Commands | Saved Response File | Description Text File |')
$markdownLines.Add('| ---: | --- | ---: | ---: | --- | --- |')
$rank = 1
foreach ($entry in $topLearnByMode[$modeName]) {
$fileLink = if ($entry.learnResponseFile) { "``$($entry.learnResponseFile)``" } else { 'n/a' }
$textLink = if ($entry.learnResponseTextFile) { "``$($entry.learnResponseTextFile)``" } else { 'n/a' }
$markdownLines.Add("| $rank | $($entry.tool) | $($entry.utf8Bytes) | $($entry.innerCommandCount) | $fileLink | $textLink |")
$rank++
}
$markdownLines.Add('')
$markdownLines.Add("## Decoded Learn Command Arrays Over $LearnResponseThresholdUtf8Bytes UTF-8 Bytes ($modeName)")
$markdownLines.Add('')
$markdownLines.Add('| Tool | Bytes | Character Count | Saved Response File | Description Text File |')
$markdownLines.Add('| --- | ---: | ---: | --- | --- |')
foreach ($entry in $largeLearnByMode[$modeName]) {
$fileLink = if ($entry.learnResponseFile) { "``$($entry.learnResponseFile)``" } else { 'n/a' }
$textLink = if ($entry.learnResponseTextFile) { "``$($entry.learnResponseTextFile)``" } else { 'n/a' }
$markdownLines.Add("| $($entry.tool) | $($entry.utf8Bytes) | $($entry.characterCount) | $fileLink | $textLink |")
}
}
Set-Content -LiteralPath $resolvedMarkdownPath -Value $markdownLines -Encoding utf8
Write-Host "Markdown summary saved to $resolvedMarkdownPath"
}
if (!$OutputDirectory) {
$OutputDirectory = Join-Path $repoRoot '.work/mcp-output-size'
}
$OutputDirectory = [IO.Path]::GetFullPath($OutputDirectory)
if ($Clean -and (Test-Path -LiteralPath $OutputDirectory)) {
Write-Host "Removing existing output directory $OutputDirectory"
Remove-Item -LiteralPath $OutputDirectory -Recurse -Force
}
New-Item -ItemType Directory -Path $OutputDirectory -Force | Out-Null
$reportPath = Join-Path $OutputDirectory 'mcp-output-size.json'
if ($ServerExecutable) {
$requestedServerExecutable = [IO.Path]::GetFullPath($ServerExecutable)
if (!(Test-Path -LiteralPath $requestedServerExecutable -PathType Leaf)) {
Write-Error "Server executable not found at $requestedServerExecutable."
exit 1
}
}
$resolvedServerExecutable = $null
if ($ReleaseTag) {
# Map the current platform to the asset name pattern used by Pack-Zip.ps1 /
# New-BuildInfo.ps1, e.g. Azure.Mcp.Server-win-x64.zip, Azure.Mcp.Server-linux-arm64.zip,
# Azure.Mcp.Server-osx-arm64.zip.
if ($IsWindows) {
$releaseOs = 'win'
} elseif ($IsMacOS) {
$releaseOs = 'osx'
} elseif ($IsLinux) {
$releaseOs = 'linux'
} else {
Write-Error "Unable to determine current OS for release asset selection."
exit 1
}
$arch = [Runtime.InteropServices.RuntimeInformation]::ProcessArchitecture
$releaseArch = switch ($arch) {
'X64' { 'x64' }
'Arm64' { 'arm64' }
default {
Write-Error "Unsupported process architecture '$arch' for release asset selection."
exit 1
}
}
$assetName = "Azure.Mcp.Server-$releaseOs-$releaseArch.zip"
$downloadUrl = "https://github.com/$GitHubRepository/releases/download/$ReleaseTag/$assetName"
$releaseDownloadDir = Join-Path $OutputDirectory "release-download/$ReleaseTag"
$releaseExtractDir = Join-Path $releaseDownloadDir 'extracted'
$releaseZipPath = Join-Path $releaseDownloadDir $assetName
New-Item -ItemType Directory -Path $releaseDownloadDir -Force | Out-Null
Write-Host "Downloading $assetName from release $ReleaseTag ($GitHubRepository)..."
Write-Host " $downloadUrl"
Invoke-WebRequest -Uri $downloadUrl -OutFile $releaseZipPath
Write-Host "Extracting $releaseZipPath..."
if (Test-Path -LiteralPath $releaseExtractDir) {
Remove-Item -LiteralPath $releaseExtractDir -Recurse -Force
}
Expand-Archive -Path $releaseZipPath -DestinationPath $releaseExtractDir -Force
$resolvedServerExecutable = Join-Path $releaseExtractDir "azmcp$(if ($IsWindows) { '.exe' } else { '' })"
if (!(Test-Path -LiteralPath $resolvedServerExecutable -PathType Leaf)) {
Write-Error "azmcp executable not found in downloaded release asset at $resolvedServerExecutable."
exit 1
}
if (!$IsWindows) {
chmod +x $resolvedServerExecutable
}
}
if (-not $resolvedServerExecutable -and $requestedServerExecutable) {
$resolvedServerExecutable = $requestedServerExecutable
}
$usingExternalServerBinary = [bool]$resolvedServerExecutable
if ($SkipBuild) {
Write-Host "Skipping build."
} else {
if ($usingExternalServerBinary) {
Write-Host "Using pre-built server executable $resolvedServerExecutable; skipping local server build."
} else {
Write-Host "Building $serverProject ($Configuration) with Build-Code.ps1..."
& "$PSScriptRoot/Build-Code.ps1" `
-ServerName 'Azure.Mcp.Server' `
-OperatingSystems $measurerOperatingSystem `
-Architectures $measurerArchitecture `
-OutputPath $serverBuildOutput `
-Trimmed `
-ReleaseBuild:($Configuration -eq 'Release')
if ($LASTEXITCODE -ne 0) {
Write-Error "Build failed with exit code $LASTEXITCODE."
exit $LASTEXITCODE
}
}
Write-Host "Building $measurerProject ($Configuration) with Build-Code.ps1..."
& "$PSScriptRoot/Build-Code.ps1" `
-BuildInfoPath $measurerBuildInfoPath `
-PlatformName $measurerRuntime `
-OutputPath $measurerBuildOutput `
-ReleaseBuild:($Configuration -eq 'Release')
if ($LASTEXITCODE -ne 0) {
Write-Error "Build failed with exit code $LASTEXITCODE."
exit $LASTEXITCODE
}
}
if ($usingExternalServerBinary) {
$serverExecutablePath = $resolvedServerExecutable
} else {
$serverExecutablePath = $localServerExecutable
if (!(Test-Path -LiteralPath $serverExecutablePath -PathType Leaf)) {
Write-Error "Server executable not found at $serverExecutablePath. Run without -SkipBuild to build it first."
exit 1
}
}
if (!(Test-Path -LiteralPath $measurerExecutable -PathType Leaf)) {
Write-Error "Measurer executable not found at $measurerExecutable. Run without -SkipBuild to build it first."
exit 1
}
Write-Host "Running MCP output size measurement..."
& $measurerExecutable --executable $serverExecutablePath --report $reportPath
if ($LASTEXITCODE -ne 0) {
Write-Error "Measurement failed with exit code $LASTEXITCODE."
exit $LASTEXITCODE
}
if (!(Test-Path -LiteralPath $reportPath -PathType Leaf)) {
Write-Error "Measurement report was not produced at $reportPath."
exit 1
}
Write-Host "Validating MCP tool counts against the maximum supported client request size ($maximumToolsPerClientRequest)..."
$measurementReport = Get-Content -LiteralPath $reportPath -Raw | ConvertFrom-Json
$exceedingModes = @(
$measurementReport.modes |
Where-Object { [int]$_.toolCount -gt $maximumToolsPerClientRequest }
)
if ($exceedingModes.Count -gt 0) {
$details = $exceedingModes |
ForEach-Object { "'$($_.mode)' has $($_.toolCount) tools" } |
Join-String -Separator ', '
$limitMessage = "MCP tool count exceeds the maximum supported client request size of ${maximumToolsPerClientRequest}: $details."
Write-Host ""
Write-Host "ERROR: $limitMessage" -ForegroundColor Red
Write-Warning $limitMessage
}
Write-Host "Summarizing results..."
Invoke-McpOutputSizeSummary `
-InputPath $reportPath `
-OutputPath (Join-Path $OutputDirectory 'mcp-output-size-summary.json') `
-MarkdownPath (Join-Path $OutputDirectory 'mcp-output-size-summary.md') `
-LearnResponseThresholdUtf8Bytes $LearnResponseThresholdUtf8Bytes
Write-Host ""
Write-Host "Done. Results are in $OutputDirectory"