mirror of
https://github.com/microsoft/mcp.git
synced 2026-11-04 12:01:31 +00:00
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:
1 parent
b17daebba6
commit
3736f3e93e
19 files changed
+695
-79
No files matched your search
@@ -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')) }}:
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
|
||||
@@ -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).
|
||||
|
||||
+19
-11
@@ -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
|
||||
}
|
||||
|
||||
+18
-18
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user