mirror of
https://github.com/microsoft/mcp.git
synced 2026-11-04 12:01:31 +00:00
* 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>
662 lines
27 KiB
PowerShell
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"
|