Export tool metadata after NuGet releases (#3867)

* Adds logic to fix JSON with control characters.

* - Update README.

* Add support for secrets.

* Add script for updating.

* Update README.md

* Update to publish metadata during release build.

* Add variables for ingestion

* Update README.

* Update exporter tool into a job file.

* Only create paths/directories when successful

* Accept whiteline characters within JSON string.

* Add query support for multiple servers.

* Update binary search to include file extension

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Add support for Template.Mcp.Server as IsDryRun

* Update comments.

* Do not fail the release stage.

* Throw when more than one server or runtime is found.

* Remove overlapping.

* Make child directory if it does not exist.

* Add Kusto to networkpolicy isolation

* Make binary executable on linux.

* Add Kusto to common.yml

* Code review comments

* Adding fetchdepth: 1

---------

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Connie YauandCopilot Autofix powered by AI authored and GitHub committed 2026-10-27 11:11:30 -07:00
1 parent b17daebba6
commit 3736f3e93e
19 files changed
+695 -79

No files matched your search

+1 -1
View File
@@ -17,7 +17,7 @@ parameters:
default: false
- name: NetworkIsolationPolicy
type: string
default: Permissive
default: Permissive,Kusto
extends:
${{ if and(parameters.Use1ESOfficial, eq(variables['System.TeamProject'], 'internal')) }}:
+2 -1
View File
@@ -8,7 +8,7 @@ parameters:
values: [none, internal, public ]
- name: NetworkIsolationPolicy
type: string
default: Permissive
default: Permissive,Kusto
- name: RunLiveTests
type: boolean
default: false
@@ -220,6 +220,7 @@ extends:
variables:
- template: /eng/pipelines/templates/variables/image.yml
- template: /eng/pipelines/templates/variables/globals.yml
- template: /eng/pipelines/templates/variables/tool-metadata-exporter.yml
jobs:
- template: /eng/pipelines/templates/jobs/release.yml
parameters:
@@ -0,0 +1,53 @@
parameters:
- name: ServerName
type: string
- name: DependsOn
type: object
jobs:
- job: ExportToolMetadata
displayName: "Export tool metadata"
# Best-effort: a failure here (including setup steps) must not fail the release stage.
continueOnError: true
dependsOn: ${{ parameters.DependsOn }}
condition: and(succeeded(), ne(variables['Skip.PublishPackage'], 'true'))
pool:
name: $(LINUXPOOL)
image: $(LINUXVMIMAGE)
os: linux
steps:
- checkout: self
fetchDepth: 1
- download: current
displayName: "Download build_info"
artifact: build_info
- download: current
displayName: "Download binaries_linux_x64"
artifact: binaries_linux_x64
- template: /eng/pipelines/templates/steps/install-dotnet.yml
- task: AzureCLI@2
displayName: "Build and run ToolMetadataExporter"
env:
AppConfig__IngestionEndpoint: $(ToolMetadataIngestionEndpoint)
AppConfig__QueryEndpoint: $(ToolMetadataQueryEndpoint)
AppConfig__DatabaseName: $(ToolMetadataDatabaseName)
inputs:
azureSubscription: "Azure SDK Engineering System"
scriptType: pscore
scriptLocation: scriptPath
scriptPath: $(Build.SourcesDirectory)/eng/scripts/Invoke-ReleaseToolMetadataExport.ps1
arguments: >
-BuildInfoPath '$(Pipeline.Workspace)/build_info/build_info.json'
-BinariesDirectory '$(Pipeline.Workspace)/binaries_linux_x64'
-ServerName '${{ parameters.ServerName }}'
-ArtifactDirectory '$(Build.ArtifactStagingDirectory)/tool-metadata-changes'
-RuntimeIdentifier 'linux-x64'
- template: /eng/common/pipelines/templates/steps/publish-1es-artifact.yml
parameters:
ArtifactPath: $(Build.ArtifactStagingDirectory)/tool-metadata-changes
ArtifactName: tool_metadata_changes
+5
View File
@@ -93,6 +93,11 @@ jobs:
ServerName: ${{ parameters.ServerName }}
DependsOn: TagRepository
- template: /eng/pipelines/templates/jobs/export-tool-metadata.yml
parameters:
ServerName: ${{ parameters.ServerName }}
DependsOn: PublishNugetPackage
- ${{ if parameters.PackageVSIX }}:
- template: /eng/pipelines/templates/jobs/vsix/release-vsix.yml
parameters:
@@ -0,0 +1,4 @@
variables:
ToolMetadataIngestionEndpoint: https://ingest-ddazureclients.kusto.windows.net
ToolMetadataQueryEndpoint: https://ddazureclients.kusto.windows.net
ToolMetadataDatabaseName: AzureDevExp
@@ -0,0 +1,137 @@
#Requires -Version 7
<#
.SYNOPSIS
Builds ToolMetadataExporter and runs it against a server executable from the build output.
.DESCRIPTION
Resolves a server and platform from build_info.json, locates the server executable under the
platform's artifact path in the binaries directory, and runs ToolMetadataExporter against it. The server must
implement `server info` and `tools list`. Template.Mcp.Server releases run in dry-run mode (no Kusto ingestion).
.PARAMETER BuildInfoPath
Path to the pipeline build_info.json artifact.
.PARAMETER BinariesDirectory
Root directory of the downloaded binaries artifact (for example, binaries_windows_x64).
.PARAMETER ServerName
Server name in build_info.json.
.PARAMETER ArtifactDirectory
Directory where generated tool-change files are staged for pipeline artifact publishing.
.PARAMETER RuntimeIdentifier
Platform name in build_info.json (for example, windows-x64).
#>
param(
[Parameter(Mandatory)]
[string] $BuildInfoPath,
[Parameter(Mandatory)]
[string] $BinariesDirectory,
[Parameter(Mandatory)]
[string] $ServerName,
[Parameter(Mandatory)]
[string] $ArtifactDirectory,
[Parameter(Mandatory)]
[string] $RuntimeIdentifier
)
$ErrorActionPreference = "Stop"
. "$PSScriptRoot/../common/scripts/common.ps1"
$RepoRoot = $RepoRoot.Path.Replace('\', '/')
Write-Host "##[section]Starting tool metadata export for '$ServerName' ($RuntimeIdentifier)"
Write-Host "Loading build information from '$BuildInfoPath'."
$buildInfo = Get-Content $BuildInfoPath -Raw | ConvertFrom-Json
$servers = $buildInfo.servers |
Where-Object { $_.name -eq $ServerName }
if ($servers.Count -eq 0) {
throw "Server '$ServerName' was not found in '$BuildInfoPath'."
} elseif ($servers.Count -gt 1) {
throw "Multiple servers with name '$ServerName' were found in '$BuildInfoPath'."
}
$server = $servers[0]
$platforms = $server.platforms |
Where-Object { $_.name -eq $RuntimeIdentifier }
if ($platforms.Count -eq 0) {
throw "Platform '$RuntimeIdentifier' was not found for server '$ServerName' in '$BuildInfoPath'."
} elseif ($platforms.Count -gt 1) {
throw "Multiple platforms with runtime identifier '$RuntimeIdentifier' were found for server '$ServerName' in '$BuildInfoPath'."
}
$platformInformation = $platforms[0]
if (-not $platformInformation) {
throw "Platform information for runtime identifier '$RuntimeIdentifier' was not found in build info for server '$ServerName'."
}
$packageDirectory = Join-Path $BinariesDirectory $platformInformation.artifactPath
Write-Host "Locating the binary '$($server.cliName)' in '$packageDirectory'."
$matching = Get-ChildItem -LiteralPath $packageDirectory -Filter "$($server.cliName)$($platformInformation.extension)" -File
if ($matching.Count -eq 0) {
throw "No binary '$($server.cliName)' was found in '$packageDirectory'."
}
if ($matching.Count -gt 1) {
throw "Expected one $RuntimeIdentifier binary '$($server.cliName)' in '$packageDirectory', but found $($matching.Count)."
}
$executablePath = $matching[0].FullName
Write-Host "Resolved server executable '$executablePath'."
if ($IsLinux) {
Write-Host "Making server executable '$executablePath' executable on Linux."
& chmod 'u+x' '--' $executablePath
if ($LASTEXITCODE -ne 0) {
throw "Failed to make '$executablePath' executable."
}
}
$projectPath = Join-Path $RepoRoot 'eng/tools/ToolMetadataExporter/src/ToolMetadataExporter.csproj'
Write-Host "##[section]Building ToolMetadataExporter"
if (!$(Test-Path $projectPath)) {
throw "ToolMetadataExporter project not found at '$projectPath'."
}
& dotnet build $projectPath --configuration Release
if ($LASTEXITCODE -ne 0) {
throw "ToolMetadataExporter build failed with exit code $LASTEXITCODE."
}
$env:AppConfig__WorkDirectory = [System.IO.Path]::GetFullPath($ArtifactDirectory)
Write-Host "Exporter output directory: '$env:AppConfig__WorkDirectory'."
if (!(Test-Path $ArtifactDirectory)) {
New-Item -ItemType Directory -Path $ArtifactDirectory | Out-Null
}
$exporterArguments = @('--AzmcpExe', $executablePath)
if ($ServerName -eq 'Template.Mcp.Server') {
Write-Host "Template server release: running exporter in dry-run mode."
$exporterArguments += @('--IsDryRun', 'true')
}
$exporterExitCode = 0
Push-Location (Split-Path $projectPath -Parent)
try {
& dotnet run --project $projectPath --configuration Release --no-build --no-launch-profile -- @exporterArguments
$exporterExitCode = $LASTEXITCODE
}
finally {
Pop-Location
}
if ($exporterExitCode -ne 0) {
throw "ToolMetadataExporter failed with exit code $exporterExitCode."
}
Write-Host "##[section]Tool metadata export completed successfully"
+129
View File
@@ -0,0 +1,129 @@
# Tool Metadata Exporter
`ToolMetadataExporter` snapshots the tools exposed by an MCP server, compares them with the latest state in Azure Data Explorer (Kusto), and records created, updated, and deleted tool events.
Each run:
1. Invokes `server info` and `tools list` on the configured MCP executable.
1. Writes the complete tool catalog to JSON.
1. Queries Kusto for the latest known tool state for that server.
1. Writes detected changes locally.
1. Ingests changes unless dry-run mode is enabled.
## Prerequisites
- The repository's configured .NET SDK.
- An MCP executable that implements both commands. The exporter is server-agnostic but depends on their contracts:
- `server info` returns JSON with the server `name` and `version`. If it is missing or not valid JSON, the exporter falls back to `--help` (name from the line after `Description:`) and `--version`.
- `tools list` returns the JSON tool catalog. The run fails if it returns no JSON.
Events are keyed by the reported server name, and the comparison against Kusto is scoped to that name.
- A Kusto cluster, database, and identity with query access. Ingestion also requires table ingestion access.
Run [`src\Resources\queries\CreateTable.kql`](src/Resources/queries/CreateTable.kql) against the target database before the first export.
> [!IMPORTANT]
> Dry-run mode skips ingestion but still queries Kusto and therefore still requires authentication and query access.
## Configuration
The project has a `UserSecretsId`. User secrets load automatically when `DOTNET_ENVIRONMENT` is `Development`:
```powershell
dotnet user-secrets --project .\src set "AppConfig:QueryEndpoint" "https://<cluster>.<region>.kusto.windows.net"
dotnet user-secrets --project .\src set "AppConfig:IngestionEndpoint" "https://ingest-<cluster>.<region>.kusto.windows.net"
dotnet user-secrets --project .\src set "AppConfig:DatabaseName" "<database>"
dotnet user-secrets --project .\src set "AppConfig:McpToolEventsTableName" "McpToolEvents"
```
Configuration can also be supplied through `src\appsettings.Development.json` or `AppConfig__*` environment variables.
| Setting | Purpose | Default |
|---|---|---|
| `QueryEndpoint` | Kusto query endpoint. | Required |
| `IngestionEndpoint` | Kusto ingestion endpoint. | Required |
| `DatabaseName` | Target database. | Required |
| `McpToolEventsTableName` | Event table. | `McpToolEvents` |
| `QueriesFolder` | Directory containing `GetAvailableTools.kql`. | `Resources\queries` |
| `WorkDirectory` | Generated JSON directory. | Repository `.work` for the repository executable; application directory for an explicit executable |
| `AzmcpExe` | MCP executable to inspect. | `eng\tools\Azmcp\azmcp.exe` |
| `IsDryRun` | Skip Kusto ingestion. | `false` |
| `UseAnalysisTime` | Use the run time instead of the executable's timestamp for events. | `false` |
## Run
Run from `src` so the default query path resolves correctly. `--no-launch-profile` avoids any developer-specific launch arguments:
```powershell
Push-Location .\src
$env:DOTNET_ENVIRONMENT = "Development"
dotnet run --no-launch-profile -- --AzmcpExe "C:\path\to\azmcp.exe" --IsDryRun true
Pop-Location
```
Remove `--IsDryRun true` to ingest detected events. The exporter only ingests when changes are found.
Authentication uses Azure CLI credentials or an interactive Microsoft Entra prompt:
```powershell
az login
```
The release pipeline uses [`Invoke-ReleaseToolMetadataExport.ps1`](../../scripts/Invoke-ReleaseToolMetadataExport.ps1) after publishing the NuGet package. The script locates the server executable from `build_info.json`, builds the exporter, records its metadata, and publishes generated `*_tool_changes_*.json` files as the `tool_metadata_changes` artifact. Releases of `Template.Mcp.Server` always run with `--IsDryRun true` so test releases never ingest events.
Release configuration comes from [`tool-metadata-exporter.yml`](../../pipelines/templates/variables/tool-metadata-exporter.yml):
| Variable | Exporter setting |
|---|---|
| `ToolMetadataIngestionEndpoint` | `AppConfig:IngestionEndpoint` |
| `ToolMetadataQueryEndpoint` | `AppConfig:QueryEndpoint` |
| `ToolMetadataDatabaseName` | `AppConfig:DatabaseName` |
## Export downloaded versions
[`src\Export-DownloadedVersions.ps1`](src/Export-DownloadedVersions.ps1) finds versioned package directories, orders them using semantic versioning, and runs the exporter against each `tools\any\win-x64\azmcp.exe`.
```powershell
.\src\Export-DownloadedVersions.ps1 `
-PackagesDirectory "C:\downloads\azure.mcp" `
-ExportUntil "3.0.0" `
-IsDryRun
```
`-ExportUntil` is inclusive. For example, `3.0.0` includes `3.0.0-beta.1` and `3.0.0`.
| Option | Behavior |
|---|---|
| `-PackagesDirectory` | Directory containing versioned package folders. |
| `-ExportUntil <version>` | Process versions up to and including the semantic version. |
| `-IsDryRun` | Pass `--IsDryRun true` to every exporter invocation. Without it, changes may be ingested. |
| `-Force` | Delete the success ledger and restart from the earliest eligible version. |
| `-SuccessfulPathsFile <path>` | Override the success-ledger path. |
| `-WhatIf` | Preview protected operations without exporting or deleting the ledger. |
Successful executable paths are appended to:
```text
.work\ToolMetadataExporter\Export-DownloadedVersions.successful.txt
```
Subsequent runs skip those paths. A failed export is not recorded. Use `-Force -WhatIf` to preview
re-exporting all eligible versions without modifying the ledger.
## Output
The exporter writes files to `WorkDirectory`:
```text
<version>_tool_<yyyyMMddHHmmss>.json
<version>_tool_changes_<yyyyMMddHHmmss>.json
```
The tool file contains the complete catalog. The change file is written only when created, updated, or deleted tools are detected.
## Troubleshooting
- **Unexpected organization-branded sign-in:** Cancel the prompt and verify the Kusto endpoints and tenant. Dry runs still authenticate.
- **`Resources\queries` not found:** Run from `src` or configure an absolute `AppConfig__QueriesFolder`.
- **Historical executable emits invalid JSON:** The exporter retries after escaping illegal C0 control characters, such as U+001A, in the response.
- **No change file:** The current catalog matches the latest Kusto state.
@@ -45,7 +45,7 @@ public class AppConfiguration
public bool IsDryRun { get; set; }
/// <summary>
/// Path to the Azure MCP executable (azmcp.exe) to analyze.
/// Path to the MCP server executable to analyze. It must implement `server info` and `tools list`.
/// </summary>
public string? AzmcpExe { get; set; }
@@ -0,0 +1,146 @@
<#
.SYNOPSIS
Exports tool metadata from downloaded Azure MCP versions in semantic-version order.
.DESCRIPTION
Finds package directories whose names end in a semantic version and invokes ToolMetadataExporter
for each tools\any\win-x64\azmcp.exe. Successfully processed executable paths are recorded in a
ledger and skipped on subsequent runs.
The script stops when an expected executable is missing or an export fails. Use -WhatIf to preview
exports and ledger deletion without performing them.
.PARAMETER PackagesDirectory
Directory containing the versioned Azure MCP package directories.
.PARAMETER SuccessfulPathsFile
Path to the success ledger. Defaults to
.work\ToolMetadataExporter\Export-DownloadedVersions.successful.txt under the repository root.
.PARAMETER ExportUntil
Inclusive semantic-version ceiling. For example, 2.0.0 includes prerelease versions such as
2.0.0-beta.1 and the stable 2.0.0 release.
.PARAMETER IsDryRun
Passes --IsDryRun true to each ToolMetadataExporter invocation. Without this switch, detected
changes may be ingested into Kusto.
.PARAMETER Force
Deletes the success ledger before processing, causing eligible versions to be exported again.
.EXAMPLE
.\Export-DownloadedVersions.ps1 -PackagesDirectory C:\downloads\azure.mcp -IsDryRun
Exports every discovered version without ingesting changes.
.EXAMPLE
.\Export-DownloadedVersions.ps1 -ExportUntil 2.0.0 -IsDryRun
Exports versions up to and including 2.0.0.
.EXAMPLE
.\Export-DownloadedVersions.ps1 -Force -WhatIf
Previews deleting the success ledger and reprocessing all eligible versions.
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[Parameter(Mandatory)]
[string] $PackagesDirectory,
[string] $SuccessfulPathsFile,
[string] $ExportUntil,
[switch] $IsDryRun,
[switch] $Force
)
$ErrorActionPreference = 'Stop'
. $PSScriptRoot/../../../common/scripts/common.ps1
$RepoRoot = $RepoRoot.Path.Replace('\', '/')
$exportUntilVersion = if ($ExportUntil) {
try {
[System.Management.Automation.SemanticVersion]$ExportUntil
}
catch {
throw "ExportUntil must be a valid semantic version. Received '$ExportUntil'."
}
}
$SuccessfulPathsFile = if ($SuccessfulPathsFile) {
[System.IO.Path]::GetFullPath($SuccessfulPathsFile)
}
else {
Join-Path $RepoRoot '.work\ToolMetadataExporter\Export-DownloadedVersions.successful.txt'
}
if ($Force -and
(Test-Path -LiteralPath $SuccessfulPathsFile -PathType Leaf) -and
$PSCmdlet.ShouldProcess($SuccessfulPathsFile, 'Delete successful paths ledger')) {
Remove-Item -LiteralPath $SuccessfulPathsFile -Force
}
$successfulPaths = [System.Collections.Generic.HashSet[string]]::new(
[System.StringComparer]::OrdinalIgnoreCase)
if (-not ($Force -and $WhatIfPreference) -and
(Test-Path -LiteralPath $SuccessfulPathsFile -PathType Leaf)) {
foreach ($path in Get-Content -LiteralPath $SuccessfulPathsFile) {
if (-not [string]::IsNullOrWhiteSpace($path)) {
$successfulPaths.Add([System.IO.Path]::GetFullPath($path.Trim())) | Out-Null
}
}
}
$packages = Get-ChildItem -LiteralPath $PackagesDirectory -Directory |
ForEach-Object {
if ($_.Name -notmatch '(?<Version>\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)$') {
Write-Verbose "Skipping '$($_.Name)' because its name does not end in a semantic version."
return
}
[pscustomobject]@{
Directory = $_
Version = [System.Management.Automation.SemanticVersion]$Matches.Version
}
} |
Where-Object { $null -eq $exportUntilVersion -or $_.Version -le $exportUntilVersion } |
Sort-Object Version
Push-Location $PSScriptRoot
try {
foreach ($package in $packages) {
$azmcpExe = Join-Path $package.Directory.FullName 'tools\any\win-x64\azmcp.exe'
if ($successfulPaths.Contains($azmcpExe)) {
Write-Host "Skipping $($package.Version); '$azmcpExe' was exported successfully in a previous run."
continue
}
if (-not (Test-Path -LiteralPath $azmcpExe -PathType Leaf)) {
throw "Could not find azmcp.exe for version $($package.Version) at '$azmcpExe'."
}
if ($PSCmdlet.ShouldProcess($azmcpExe, "Export tool metadata for version $($package.Version)")) {
$arguments = @('run', '--', '--AzmcpExe', $azmcpExe)
if ($IsDryRun) {
$arguments += @('--IsDryRun', 'true')
}
Write-Host "Exporting tool metadata for $($package.Version)..."
& dotnet @arguments
if ($LASTEXITCODE -ne 0) {
throw "Tool metadata export failed for version $($package.Version) with exit code $LASTEXITCODE."
}
$successfulPathsDirectory = Split-Path -Parent $SuccessfulPathsFile
New-Item -ItemType Directory -Path $successfulPathsDirectory -Force | Out-Null
Add-Content -LiteralPath $SuccessfulPathsFile -Value $azmcpExe
$successfulPaths.Add($azmcpExe) | Out-Null
}
}
}
finally {
Pop-Location
}
@@ -1,39 +0,0 @@
# Tool Metadata Exporter
Extracts tool metadata from the MCP server and exports it to an Azure Data Explorer (Kusto) database for further analysis and reporting.
## Development Setup
### 1. Create Datastore
1. Create an Azure Data Explorer Cluster (e.g. "mcp-test-instance")
1. Create a database (e.g. "McpToolMetadata") within the cluster
1. In https://dataexplorer.azure.com/, connect to cluster
1. Click on "Query" tab
1. Execute [`CreateTable.kql`](https://github.com/microsoft/mcp/tree/main/eng/tools/ToolMetadataExporter/src/Resources/queries/CreateTable.kql) against the cluster and database
### 2. Configure Application
1. Open [`appsettings.Development.json`](https://github.com/microsoft/mcp/tree/main/eng/tools/ToolMetadataExporter/src/appsettings.Development.json)
1. Update "IngestionEndpoint", "QueryEndpoint", "DatabaseName" with the appropriate cluster and database names. Using the example from previous step, it would look like this:
```json
"AppConfig": {
"IngestionEndpoint": "https://ingest-mcp-test-instance.westus2.kusto.windows.net",
"QueryEndpoint": "https://mcp-test-instance.westus2.kusto.windows.net",
"DatabaseName": "McpToolMetadata"
}
```
To find values for "QueryEndpoint" and "IngestionEndpoint", navigate to the Azure Data Explorer Cluster in the Azure portal, and in the "Essentials" panel window, look for. Other settings in `appsettings.json` can be overridden here as needed.
1. "IngestionEndpoint" is "Data Ingestion URI"
1. "QueryEndpoint" is "URI"
Additional configuration settings and their documentation can be found in [AppConfiguration.cs]<!--(https://github.com/microsoft/mcp/tree/main/eng/tools/ToolMetadataExporter/src/AppConfiguration.cs)-->.
### 3. Run Application
1. Open a terminal in the project src directory: [$RepositoryRoot/eng/tools/ToolMetadataExporter/src](https://github.com/microsoft/mcp/tree/main/eng/tools/ToolMetadataExporter/src)
1. Run the application using the command:
```bash
dotnet run --environment DOTNET_ENVIRONMENT=Development
```
The environment variable `DOTNET_ENVIRONMENT` is set to `Development` to ensure the application uses [`appsettings.Development.json`](https://github.com/microsoft/mcp/tree/main/eng/tools/ToolMetadataExporter/src/appsettings.Development.json)
@@ -1,4 +1,6 @@
declare query_parameters(serverName:string);
McpToolEvents
| where ServerName =~ serverName
| summarize arg_max(EventTime, *) by ToolId
| where EventType != 'Deleted'
| project EventTime, ToolId, ToolName, ToolArea, ServerVersion, ServerName, EventType, ReplacedByToolName, ReplacedByToolArea
@@ -46,13 +46,17 @@ public class AzureMcpKustoDatastore : IAzureMcpDatastore
}
}
public async Task<IList<AzureMcpTool>> GetAvailableToolsAsync(CancellationToken cancellationToken = default)
public async Task<IList<AzureMcpTool>> GetAvailableToolsAsync(
string serverName,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(serverName);
var queryFile = _queriesDirectory.GetFiles(ExistingToolsKqlFileName).FirstOrDefault() ?? throw new InvalidOperationException($"Could not find {ExistingToolsKqlFileName} in {_queriesDirectory.FullName}");
var results = new List<AzureMcpTool>();
await foreach (var latestEvent in GetLatestToolEventsAsync(queryFile.FullName, cancellationToken))
await foreach (var latestEvent in GetLatestToolEventsAsync(queryFile.FullName, serverName, cancellationToken))
{
cancellationToken.ThrowIfCancellationRequested();
@@ -130,9 +134,13 @@ public class AzureMcpKustoDatastore : IAzureMcpDatastore
}
}
internal async IAsyncEnumerable<McpToolEvent> GetLatestToolEventsAsync(string kqlFilePath,
internal async IAsyncEnumerable<McpToolEvent> GetLatestToolEventsAsync(
string kqlFilePath,
string serverName,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(serverName);
if (!File.Exists(kqlFilePath))
{
throw new FileNotFoundException($"KQL file not found: {kqlFilePath}");
@@ -141,6 +149,7 @@ public class AzureMcpKustoDatastore : IAzureMcpDatastore
var kql = await File.ReadAllTextAsync(kqlFilePath, cancellationToken);
var clientRequestProperties = new ClientRequestProperties();
clientRequestProperties.SetParameter("serverName", serverName);
IDataReader reader = await _kustoClient.ExecuteQueryAsync(_databaseName, kql, clientRequestProperties, cancellationToken);
var eventTimeOrdinal = reader.GetOrdinal(nameof(McpToolEvent.EventTime));
@@ -8,7 +8,9 @@ namespace ToolMetadataExporter.Services;
public interface IAzureMcpDatastore
{
Task<IList<AzureMcpTool>> GetAvailableToolsAsync(CancellationToken cancellationToken = default);
Task<IList<AzureMcpTool>> GetAvailableToolsAsync(
string serverName,
CancellationToken cancellationToken = default);
Task AddToolEventsAsync(List<McpToolEvent> toolEvents, CancellationToken cancellationToken = default);
}
@@ -68,7 +68,7 @@ public class ToolAnalyzer
cancellationToken.ThrowIfCancellationRequested();
var existingTools = (await _azureMcpDatastore.GetAvailableToolsAsync(cancellationToken)).ToDictionary(x => x.ToolId);
var existingTools = (await _azureMcpDatastore.GetAvailableToolsAsync(serverName, cancellationToken)).ToDictionary(x => x.ToolId);
if (cancellationToken.IsCancellationRequested)
{
@@ -7,6 +7,7 @@
<InvariantGlobalization>true</InvariantGlobalization>
<!-- Enable strongly typed binding for IConfiguration to support AOT and trimming. -->
<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
<UserSecretsId>f8ab031a-6160-4218-b776-51a5b6fe2d17</UserSecretsId>
</PropertyGroup>
<ItemGroup>
+87 -3
View File
@@ -2,6 +2,7 @@
// Licensed under the MIT License.
using System.Diagnostics;
using System.Text;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using ToolSelection.Models;
@@ -33,10 +34,24 @@ public partial class Utility(ILogger<Utility> logger)
throw new InvalidOperationException("No JSON output found from azmcp command.");
}
// Parse the JSON output
var result = JsonSerializer.Deserialize(jsonOutput, SourceGenerationContext.Default.ListToolsResult);
try
{
return JsonSerializer.Deserialize(jsonOutput, SourceGenerationContext.Default.ListToolsResult);
}
catch (JsonException ex)
{
var sanitizedJson = EscapeInvalidJsonStringControlCharacters(jsonOutput);
if (string.Equals(sanitizedJson, jsonOutput, StringComparison.Ordinal))
{
throw;
}
return result;
_logger.LogWarning(
ex,
"azmcp returned JSON containing unescaped control characters. Retrying with escaped values.");
return JsonSerializer.Deserialize(sanitizedJson, SourceGenerationContext.Default.ListToolsResult);
}
}
catch (Exception)
{
@@ -211,6 +226,75 @@ public partial class Utility(ILogger<Utility> logger)
.Replace(UnicodeChars.RightDoubleQuote, "\"");
}
/// <summary>
/// Escapes raw control characters inside JSON strings while preserving structural whitespace.
/// Historical azmcp versions emitted characters such as U+001A unescaped inside tool descriptions.
/// </summary>
private static string EscapeInvalidJsonStringControlCharacters(string json)
{
var inString = false;
var escaped = false;
StringBuilder? sanitized = null;
for (var index = 0; index < json.Length; index++)
{
var character = json[index];
if (!inString)
{
sanitized?.Append(character);
inString = character == '"';
continue;
}
if (escaped)
{
if (character < ' ')
{
sanitized ??= new StringBuilder(json.Length + 6).Append(json, 0, index);
sanitized.Append('\\');
AppendUnicodeEscape(sanitized, character);
}
else
{
sanitized?.Append(character);
}
escaped = false;
continue;
}
if (character == '\\')
{
sanitized?.Append(character);
escaped = true;
continue;
}
if (character == '"')
{
sanitized?.Append(character);
inString = false;
continue;
}
if (character < ' ')
{
sanitized ??= new StringBuilder(json.Length + 6).Append(json, 0, index);
AppendUnicodeEscape(sanitized, character);
}
else
{
sanitized?.Append(character);
}
}
return sanitized?.ToString() ?? json;
static void AppendUnicodeEscape(StringBuilder builder, char character) =>
builder.Append($"\\u{(int)character:X4}");
}
/// <summary>
/// Traverse up from a starting directory to find the repo root.
/// Directory containing <see cref="Constants.RepositoryRootSolution"/> or .git).
@@ -15,6 +15,8 @@ namespace ToolMetadataExporter.Tests.Services;
public class AzureMcpKustoDatastoreTests : IDisposable
{
private const string ServerName = "test.server";
private readonly ICslQueryProvider _kustoClient;
private readonly IKustoIngestClient _ingestClient;
private readonly ILogger<AzureMcpKustoDatastore> _logger;
@@ -141,7 +143,7 @@ public class AzureMcpKustoDatastoreTests : IDisposable
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
async () => await datastore.GetAvailableToolsAsync(TestContext.Current.CancellationToken));
async () => await datastore.GetAvailableToolsAsync(ServerName, TestContext.Current.CancellationToken));
Assert.Contains("Could not find GetAvailableTools.kql", exception.Message);
}
@@ -177,7 +179,7 @@ public class AzureMcpKustoDatastoreTests : IDisposable
var datastore = new AzureMcpKustoDatastore(_kustoClient, _ingestClient, _options, _logger);
// Act
var result = await datastore.GetAvailableToolsAsync(TestContext.Current.CancellationToken);
var result = await datastore.GetAvailableToolsAsync(ServerName, TestContext.Current.CancellationToken);
// Assert
Assert.NotNull(result);
@@ -185,6 +187,12 @@ public class AzureMcpKustoDatastoreTests : IDisposable
Assert.Equal("tool-1", result[0].ToolId);
Assert.Equal("TestTool", result[0].ToolName);
Assert.Equal("TestArea", result[0].ToolArea);
await _kustoClient.Received(1).ExecuteQueryAsync(
_appConfiguration.DatabaseName,
Arg.Any<string>(),
Arg.Is<ClientRequestProperties>(
properties => properties.Parameters["serverName"] == ServerName),
Arg.Any<CancellationToken>());
}
[Fact]
@@ -219,7 +227,7 @@ public class AzureMcpKustoDatastoreTests : IDisposable
var datastore = new AzureMcpKustoDatastore(_kustoClient, _ingestClient, _options, _logger);
// Act
var result = await datastore.GetAvailableToolsAsync(TestContext.Current.CancellationToken);
var result = await datastore.GetAvailableToolsAsync(ServerName, TestContext.Current.CancellationToken);
// Assert
Assert.NotNull(result);
@@ -262,7 +270,7 @@ public class AzureMcpKustoDatastoreTests : IDisposable
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
async () => await datastore.GetAvailableToolsAsync(TestContext.Current.CancellationToken));
async () => await datastore.GetAvailableToolsAsync(ServerName, TestContext.Current.CancellationToken));
Assert.Contains("unsupported event type", exception.Message);
}
@@ -299,7 +307,7 @@ public class AzureMcpKustoDatastoreTests : IDisposable
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
async () => await datastore.GetAvailableToolsAsync(TestContext.Current.CancellationToken));
async () => await datastore.GetAvailableToolsAsync(ServerName, TestContext.Current.CancellationToken));
Assert.Contains("Cannot have an event with no id", exception.Message);
}
@@ -336,7 +344,7 @@ public class AzureMcpKustoDatastoreTests : IDisposable
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
async () => await datastore.GetAvailableToolsAsync(TestContext.Current.CancellationToken));
async () => await datastore.GetAvailableToolsAsync(ServerName, TestContext.Current.CancellationToken));
Assert.Contains("without tool name and/or a tool area", exception.Message);
}
@@ -373,7 +381,7 @@ public class AzureMcpKustoDatastoreTests : IDisposable
// Act & Assert
var exception = await Assert.ThrowsAsync<InvalidOperationException>(
async () => await datastore.GetAvailableToolsAsync(TestContext.Current.CancellationToken));
async () => await datastore.GetAvailableToolsAsync(ServerName, TestContext.Current.CancellationToken));
Assert.Contains("without tool name and/or a tool area", exception.Message);
}
@@ -420,7 +428,7 @@ public class AzureMcpKustoDatastoreTests : IDisposable
var datastore = new AzureMcpKustoDatastore(_kustoClient, _ingestClient, _options, _logger);
// Act
var result = await datastore.GetAvailableToolsAsync(TestContext.Current.CancellationToken);
var result = await datastore.GetAvailableToolsAsync(ServerName, TestContext.Current.CancellationToken);
// Assert
Assert.NotNull(result);
@@ -543,7 +551,7 @@ public class AzureMcpKustoDatastoreTests : IDisposable
// Act & Assert
await Assert.ThrowsAsync<FileNotFoundException>(async () =>
{
await foreach (var _ in datastore.GetLatestToolEventsAsync(nonExistentFile, TestContext.Current.CancellationToken))
await foreach (var _ in datastore.GetLatestToolEventsAsync(nonExistentFile, ServerName, TestContext.Current.CancellationToken))
{
// Should not reach here
}
@@ -583,7 +591,7 @@ public class AzureMcpKustoDatastoreTests : IDisposable
// Act
var results = new List<McpToolEvent>();
await foreach (var toolEvent in datastore.GetLatestToolEventsAsync(queryFile, TestContext.Current.CancellationToken))
await foreach (var toolEvent in datastore.GetLatestToolEventsAsync(queryFile, ServerName, TestContext.Current.CancellationToken))
{
results.Add(toolEvent);
}
@@ -633,7 +641,7 @@ public class AzureMcpKustoDatastoreTests : IDisposable
// Act & Assert
await Assert.ThrowsAsync<InvalidOperationException>(async () =>
{
await foreach (var _ in datastore.GetLatestToolEventsAsync(queryFile, TestContext.Current.CancellationToken))
await foreach (var _ in datastore.GetLatestToolEventsAsync(queryFile, ServerName, TestContext.Current.CancellationToken))
{
// Should not reach here
}
@@ -106,7 +106,7 @@ public class ToolAnalyzerTests : IDisposable
await analyzer.RunAsync(DateTimeOffset.UtcNow, TestContext.Current.CancellationToken);
// Assert
await _datastore.DidNotReceive().GetAvailableToolsAsync(Arg.Any<CancellationToken>());
await _datastore.DidNotReceive().GetAvailableToolsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
await _datastore.DidNotReceive().AddToolEventsAsync(Arg.Any<List<McpToolEvent>>(), Arg.Any<CancellationToken>());
}
@@ -128,7 +128,7 @@ public class ToolAnalyzerTests : IDisposable
await analyzer.RunAsync(DateTimeOffset.UtcNow, TestContext.Current.CancellationToken);
// Assert
await _datastore.DidNotReceive().GetAvailableToolsAsync(Arg.Any<CancellationToken>());
await _datastore.DidNotReceive().GetAvailableToolsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>());
await _datastore.DidNotReceive().AddToolEventsAsync(Arg.Any<List<McpToolEvent>>(), Arg.Any<CancellationToken>());
}
@@ -151,7 +151,7 @@ public class ToolAnalyzerTests : IDisposable
_azmcpProgram.GetServerNameAsync().Returns(serverName);
_azmcpProgram.GetServerVersionAsync().Returns(serverVersion);
_azmcpProgram.LoadToolsDynamicallyAsync().Returns(toolsResult);
_datastore.GetAvailableToolsAsync(Arg.Any<CancellationToken>()).Returns(availableTools);
_datastore.GetAvailableToolsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(availableTools);
var analyzer = new ToolAnalyzer(_azmcpProgram, _datastore, _runInformation, _options, _logger);
@@ -180,7 +180,7 @@ public class ToolAnalyzerTests : IDisposable
_azmcpProgram.GetServerNameAsync().Returns(serverName);
_azmcpProgram.GetServerVersionAsync().Returns(serverVersion);
_azmcpProgram.LoadToolsDynamicallyAsync().Returns(toolsResult);
_datastore.GetAvailableToolsAsync(Arg.Any<CancellationToken>()).Returns(availableTools);
_datastore.GetAvailableToolsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(availableTools);
var analyzer = new ToolAnalyzer(_azmcpProgram, _datastore, _runInformation, _options, _logger);
@@ -216,7 +216,7 @@ public class ToolAnalyzerTests : IDisposable
_azmcpProgram.GetServerVersionAsync().Returns(serverVersion);
_azmcpProgram.LoadToolsDynamicallyAsync().Returns(result);
_datastore.GetAvailableToolsAsync(Arg.Any<CancellationToken>()).Returns(Task.FromResult(existingTools));
_datastore.GetAvailableToolsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(Task.FromResult(existingTools));
var analyzer = new ToolAnalyzer(_azmcpProgram, _datastore, _runInformation, _options, _logger);
@@ -224,7 +224,7 @@ public class ToolAnalyzerTests : IDisposable
await analyzer.RunAsync(DateTimeOffset.UtcNow, TestContext.Current.CancellationToken);
// Assert
await _datastore.Received(1).GetAvailableToolsAsync(Arg.Any<CancellationToken>());
await _datastore.Received(1).GetAvailableToolsAsync("test-server", Arg.Any<CancellationToken>());
await _datastore.Received(numberOfExpectedDatabaseUpdateCalls)
.AddToolEventsAsync(Arg.Any<List<McpToolEvent>>(), Arg.Any<CancellationToken>());
}
@@ -252,7 +252,7 @@ public class ToolAnalyzerTests : IDisposable
_azmcpProgram.GetServerNameAsync().Returns(serverNameResult);
_azmcpProgram.GetServerVersionAsync().Returns(serverVersionResult);
_azmcpProgram.LoadToolsDynamicallyAsync().Returns(toolsResult);
_datastore.GetAvailableToolsAsync(Arg.Any<CancellationToken>()).Returns(availableTools);
_datastore.GetAvailableToolsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(availableTools);
var analyzer = new ToolAnalyzer(_azmcpProgram, _datastore, _runInformation, _options, _logger);
@@ -298,7 +298,7 @@ public class ToolAnalyzerTests : IDisposable
_azmcpProgram.GetServerNameAsync().Returns(serverNameResult);
_azmcpProgram.GetServerVersionAsync().Returns(serverVersionResult);
_azmcpProgram.LoadToolsDynamicallyAsync().Returns(result);
_datastore.GetAvailableToolsAsync(Arg.Any<CancellationToken>()).Returns(availableTools);
_datastore.GetAvailableToolsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(availableTools);
var analyzer = new ToolAnalyzer(_azmcpProgram, _datastore, _runInformation, _options, _logger);
@@ -341,7 +341,7 @@ public class ToolAnalyzerTests : IDisposable
_azmcpProgram.GetServerNameAsync().Returns(serverNameResult);
_azmcpProgram.GetServerVersionAsync().Returns(serverVersionResult);
_azmcpProgram.LoadToolsDynamicallyAsync().Returns(result);
_datastore.GetAvailableToolsAsync(Arg.Any<CancellationToken>()).Returns(availableTools);
_datastore.GetAvailableToolsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(availableTools);
_datastore.AddToolEventsAsync(Arg.Any<List<McpToolEvent>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
@@ -385,7 +385,7 @@ public class ToolAnalyzerTests : IDisposable
_azmcpProgram.GetServerNameAsync().Returns(serverName);
_azmcpProgram.GetServerVersionAsync().Returns(serverVersion);
_azmcpProgram.LoadToolsDynamicallyAsync().Returns(result);
_datastore.GetAvailableToolsAsync(Arg.Any<CancellationToken>()).Returns(availableTools);
_datastore.GetAvailableToolsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(availableTools);
var analyzer = new ToolAnalyzer(_azmcpProgram, _datastore, _runInformation, _options, _logger);
@@ -425,7 +425,7 @@ public class ToolAnalyzerTests : IDisposable
_azmcpProgram.GetServerNameAsync().Returns(serverName);
_azmcpProgram.GetServerVersionAsync().Returns(serverVersion);
_azmcpProgram.LoadToolsDynamicallyAsync().Returns(toolsResult);
_datastore.GetAvailableToolsAsync(Arg.Any<CancellationToken>()).Returns(availableTools);
_datastore.GetAvailableToolsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(availableTools);
var analyzer = new ToolAnalyzer(_azmcpProgram, _datastore, _runInformation, _options, _logger);
@@ -466,7 +466,7 @@ public class ToolAnalyzerTests : IDisposable
_azmcpProgram.GetServerVersionAsync().Returns(serverVersion);
_azmcpProgram.AzMcpBuildDateTime.Returns(buildDate);
_azmcpProgram.LoadToolsDynamicallyAsync().Returns(toolsResult);
_datastore.GetAvailableToolsAsync(Arg.Any<CancellationToken>()).Returns(availableTools);
_datastore.GetAvailableToolsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(availableTools);
var analyzer = new ToolAnalyzer(_azmcpProgram, _datastore, _runInformation, _options, _logger);
@@ -511,7 +511,7 @@ public class ToolAnalyzerTests : IDisposable
_azmcpProgram.GetServerNameAsync().Returns(serverName);
_azmcpProgram.GetServerVersionAsync().Returns(serverVersion);
_azmcpProgram.LoadToolsDynamicallyAsync().Returns(toolsResult);
_datastore.GetAvailableToolsAsync(Arg.Any<CancellationToken>()).Returns(availableTools);
_datastore.GetAvailableToolsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(availableTools);
_datastore.AddToolEventsAsync(Arg.Any<List<McpToolEvent>>(), Arg.Any<CancellationToken>())
.Returns(Task.CompletedTask);
@@ -550,7 +550,7 @@ public class ToolAnalyzerTests : IDisposable
_azmcpProgram.GetServerNameAsync().Returns(serverName);
_azmcpProgram.GetServerVersionAsync().Returns(serverVersion);
_azmcpProgram.LoadToolsDynamicallyAsync().Returns(toolsResult);
_datastore.GetAvailableToolsAsync(Arg.Any<CancellationToken>()).Returns(availableTools);
_datastore.GetAvailableToolsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(availableTools);
var analyzer = new ToolAnalyzer(_azmcpProgram, _datastore, _runInformation, _options, _logger);
@@ -582,7 +582,7 @@ public class ToolAnalyzerTests : IDisposable
_azmcpProgram.GetServerNameAsync().Returns(serverName);
_azmcpProgram.GetServerVersionAsync().Returns(serverVersion);
_azmcpProgram.LoadToolsDynamicallyAsync().Returns(toolsResult);
_datastore.GetAvailableToolsAsync(Arg.Any<CancellationToken>()).Returns(availableTools);
_datastore.GetAvailableToolsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(availableTools);
var analyzer = new ToolAnalyzer(_azmcpProgram, _datastore, _runInformation, _options, _logger);
@@ -619,7 +619,7 @@ public class ToolAnalyzerTests : IDisposable
_azmcpProgram.GetServerNameAsync().Returns(serverName);
_azmcpProgram.GetServerVersionAsync().Returns(serverVersion);
_azmcpProgram.LoadToolsDynamicallyAsync().Returns(toolsResult);
_datastore.GetAvailableToolsAsync(Arg.Any<CancellationToken>()).Returns(availableTools);
_datastore.GetAvailableToolsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(availableTools);
var analyzer = new ToolAnalyzer(_azmcpProgram, _datastore, _runInformation, _options, _logger);
@@ -658,7 +658,7 @@ public class ToolAnalyzerTests : IDisposable
_azmcpProgram.GetServerNameAsync().Returns(serverName);
_azmcpProgram.GetServerVersionAsync().Returns(serverVersion);
_azmcpProgram.LoadToolsDynamicallyAsync().Returns(toolsResult);
_datastore.GetAvailableToolsAsync(Arg.Any<CancellationToken>()).Returns(availableTools);
_datastore.GetAvailableToolsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(availableTools);
var analyzer = new ToolAnalyzer(_azmcpProgram, _datastore, _runInformation, _options, _logger);
@@ -696,7 +696,7 @@ public class ToolAnalyzerTests : IDisposable
_azmcpProgram.GetServerNameAsync().Returns(serverName);
_azmcpProgram.GetServerVersionAsync().Returns(serverVersion);
_azmcpProgram.LoadToolsDynamicallyAsync().Returns(result);
_datastore.GetAvailableToolsAsync(Arg.Any<CancellationToken>()).Returns(availableTools);
_datastore.GetAvailableToolsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()).Returns(availableTools);
var analyzer = new ToolAnalyzer(_azmcpProgram, _datastore, _runInformation, _options, _logger);
@@ -0,0 +1,74 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Text.Json;
using Microsoft.Extensions.Logging;
using NSubstitute;
using NSubstitute.Extensions;
using Xunit;
namespace ToolMetadataExporter.Tests;
public class UtilityTests
{
[Fact]
public async Task LoadToolsDynamicallyAsync_EscapesUnescapedControlCharactersInJsonStrings()
{
const string ServerFile = "azmcp.exe";
var output =
"""{"status":200,"message":"Success","results":[{"name":"test","description":"before"""
+ '\u001A'
+ """after","command":"test"}]}""";
var logger = Substitute.For<ILogger<Utility>>();
var utility = Substitute.ForPartsOf<Utility>(logger);
utility.Configure()
.ExecuteAzmcpAsync(ServerFile, "tools list", false, true)
.Returns(Task.FromResult(output));
var result = await utility.LoadToolsDynamicallyAsync(ServerFile, string.Empty);
var tool = Assert.Single(result!.Tools!);
Assert.Equal($"before{'\u001A'}after", tool.Description);
}
[Fact]
public async Task LoadToolsDynamicallyAsync_EscapesWhitespaceControlCharactersInsideJsonStrings()
{
const string ServerFile = "azmcp.exe";
const string Description = "before\tmiddle\r\nafter";
var output =
"{\r\n"
+ " \"status\": 200, \"results\": [{\"name\":\"test\",\"description\":\""
+ Description
+ "\",\"command\":\"test\"}]}"
+ "\r\n";
var logger = Substitute.For<ILogger<Utility>>();
var utility = Substitute.ForPartsOf<Utility>(logger);
utility.Configure()
.ExecuteAzmcpAsync(ServerFile, "tools list", false, true)
.Returns(Task.FromResult(output));
var result = await utility.LoadToolsDynamicallyAsync(ServerFile, string.Empty);
var tool = Assert.Single(result!.Tools!);
Assert.Equal(Description, tool.Description);
}
[Fact]
public async Task LoadToolsDynamicallyAsync_DoesNotMaskOtherInvalidJson()
{
const string ServerFile = "azmcp.exe";
const string Output = """{"status": 200, "results": [}""";
var logger = Substitute.For<ILogger<Utility>>();
var utility = Substitute.ForPartsOf<Utility>(logger);
utility.Configure()
.ExecuteAzmcpAsync(ServerFile, "tools list", false, true)
.Returns(Task.FromResult(Output));
await Assert.ThrowsAsync<JsonException>(
() => utility.LoadToolsDynamicallyAsync(ServerFile, string.Empty));
}
}