diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 3153493e2b..ea0606e62a 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,5 +1,5 @@ { - "image": "mcr.microsoft.com/devcontainers:dev-10.0-preview", + "image": "mcr.microsoft.com/devcontainers/dotnet:dev-10.0-preview", "name": "Microsoft MCP Codespace", "features": { "ghcr.io/devcontainers/features/node:1": { @@ -7,6 +7,7 @@ }, "ghcr.io/devcontainers/features/azure-cli:1": {}, "ghcr.io/devcontainers/features/powershell:1": {}, + "ghcr.io/devcontainers/features/docker-in-docker:2": {}, "ghcr.io/azure/azure-dev/azd:0": {} }, "hostRequirements": { diff --git a/.editorconfig b/.editorconfig index fc005683a3..9c0d816014 100644 --- a/.editorconfig +++ b/.editorconfig @@ -164,6 +164,9 @@ dotnet_style_require_accessibility_modifiers = for_non_interface_members:suggest # Analyzers dotnet_code_quality.ca1802.api_surface = private, internal +# CA2016: Forward the CancellationToken parameter to methods that take one +dotnet_diagnostic.CA2016.severity = error + # Xml project files [*.{csproj,vcxproj,vcxproj.filters,proj,nativeproj,locproj}] indent_size = 2 diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 502b447195..6e6e486495 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -42,7 +42,7 @@ # ServiceOwners: @shenmuxiaosen @avanigupta # PRLabel: %area-AppLens -/tools/Azure.Mcp.Tools.AppLens/ @masalaman @microsoft/azure-mcp +/tools/Azure.Mcp.Tools.AppLens/ @msalaman @microsoft/azure-mcp # ServiceLabel: %area-AppLens # ServiceOwners: @msalaman @@ -59,6 +59,17 @@ # ServiceLabel: %tools-ACR # ServiceOwners: @jongio +# PRLabel: %tools-AppService +/tools/Azure.Mcp.Tools.AppService/ @KarishmaGhiya @microsoft/azure-mcp + +# ServiceLabel: %tools-AppService +# ServiceOwners: @ArthurMa1978 @weidongxu-microsoft + +# PRLabel: %tools-AIBestPractices +/tools/Azure.Mcp.Tools.AzureAIBestPractices/ @XiaofuHuang @microsoft/azure-mcp + +# ServiceLabel: %tools-AIBestPractices +# ServiceOwners: @XiaofuHuang # PRLabel: %tools-BestPractices /tools/Azure.Mcp.Tools.AzureBestPractices/ @g2vinay @conniey @fanyang-mono @microsoft/azure-mcp @@ -72,6 +83,12 @@ # ServiceLabel: %tools-CloudArchitect # ServiceOwners: @msalaman +# PRLabel: %tools-Communication +/tools/Azure.Mcp.Tools.Communication/ @KarishmaGhiya @microsoft/azure-mcp + +# ServiceLabel: %tools-Communication +# ServiceOwners: @kirill-linnik @kagbakpem @arazan + # PRLabel: %tools-CosmosDB /tools/Azure.Mcp.Tools.Cosmos/ @sajeetharan @xiangyan99 @microsoft/azure-mcp @@ -110,16 +127,16 @@ # ServiceOwners: @vcolin7 @JonathanCrd # PRLabel: %tools-ISV -/tools/Azure.Mcp.Tools.AzureIsv/ @jayanthjj @pachaturevedi @microsoft/azure-mcp +/tools/Azure.Mcp.Tools.AzureIsv/ @pachaturvedi @agrimayadav @microsoft/azure-mcp # ServiceLabel: %tools-ISV -# ServiceOwners: @jayanthjj @pachaturevedi +# ServiceOwners: @pachaturvedi @agrimayadav # PRLabel: %tools-Kusto -/tools/Azure.Mcp.Tools.Kusto/ @danield137 @xiangyan99 @microsoft/azure-mcp +/tools/Azure.Mcp.Tools.Kusto/ @prvavill @danield137 @microsoft/azure-mcp # ServiceLabel: %tools-Kusto -# ServiceOwners: @danield137 +# ServiceOwners: @prvavill @danield137 # PRLabel: %tools-Marketplace @@ -130,14 +147,14 @@ # PRLabel: %tools-Monitor -/tools/Azure.Mcp.Tools.Monitor/ @smritiy @srnagar @jongio @microsoft/azure-mcp +/tools/Azure.Mcp.Tools.Monitor/ @smritiy @srnagar @jongio @zaaslam @microsoft/azure-mcp # ServiceLabel: %tools-Monitor -# ServiceOwners: @smritiy @srnagar @jongio +# ServiceOwners: @smritiy @srnagar @jongio @zaaslam -# PRLabel: %tools-AzureManagedLustre -/tools/Azure.Mcp.Tools.AzureManagedLustre/ @wolfgang-desalvador @microsoft/azure-mcp -# ServiceLabel: %tools-AzureManagedLustre +# PRLabel: %tools-ManagedLustre +/tools/Azure.Mcp.Tools.ManagedLustre/ @wolfgang-desalvador @microsoft/azure-mcp +# ServiceLabel: %tools-ManagedLustre # ServiceOwners: @wolfgang-desalvador # PRLabel: %tools-MySQL @@ -173,7 +190,7 @@ # ServiceOwners: @shankarsama @EldertGrootenboer # PRLabel: %tools-Redis -/tools/Azure.Mcp.Tools.Redis/ @philon-msft @xiangyan99 @microsoft/azure-mcp +/tools/Azure.Mcp.Tools.Redis/ @philon-msft @sharedferret @xiangyan99 @microsoft/azure-mcp # ServiceLabel: %tools-Redis # ServiceOwners: @philon-msft @carldc @@ -198,10 +215,10 @@ # ServiceOwners: @qianwens @xiaofanzhou # PRLabel: %tools-LoadTesting -/tools/Azure.Mcp.Tools.LoadTesting/ @nishtha489 @knarayanana @krchanda @johnsta @microsoft/azure-mcp +/tools/Azure.Mcp.Tools.LoadTesting/ @nishtha489 @krisnaray @krishna1s @johnsta @microsoft/azure-mcp # ServiceLabel: %tools-LoadTesting -# ServiceOwners: @nishtha489 @knarayanana @krchanda @johnsta +# ServiceOwners: @nishtha489 @krisnaray @krishna1s @johnsta # PRLabel: %tools-VirtualDesktop /tools/Azure.Mcp.Tools.VirtualDesktop/ @vladimisms @microsoft/azure-mcp @@ -222,3 +239,28 @@ # ServiceLabel: %tools-Workbooks # ServiceOwners: @matteing + +# PRLabel: %tools-SignalR +/tools/Azure.Mcp.Tools.SignalR/ @kenchennt @JialinXin @HaofanLiao @microsoft/azure-mcp + +# ServiceLabel: %tools-SignalR +# ServiceOwners: @kenchennt @JialinXin + +# PRLabel: %tools-ConfidentialLedger +/tools/Azure.Mcp.Tools.ConfidentialLedger/ @taicchoumsft @ivarprudnikov @microsoft/azure-mcp + +# ServiceLabel: %tools-ConfidentialLedger +# ServiceOwners: @taicchoumsft @ivarprudnikov + +# PRLabel: %tools-ResourceHealth +/tools/Azure.Mcp.Tools.ResourceHealth/ @pkaza-msft @microsoft/azure-mcp + +# ServiceLabel: %tools-ResourceHealth +# ServiceOwners: @pkaza-msft @microsoft/azure-mcp + +################## +# Fabric MCP +################## +/core/Microsoft.Fabric.Mcp.Core/ @microsoft/fabric-mcp +/servers/Fabric.Mcp.Server/ @microsoft/fabric-mcp +/tools/Fabric.Mcp.Tools.PublicApi/ @microsoft/fabric-mcp diff --git a/.github/ISSUE_TEMPLATE/01_bug_bash_mcp_report.yml b/.github/ISSUE_TEMPLATE/01_bug_bash_mcp_report.yml new file mode 100644 index 0000000000..99dee10ad2 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/01_bug_bash_mcp_report.yml @@ -0,0 +1,56 @@ +name: Azure MCP - Bug Bash Report +description: "File a bug found during Bug Bash for the Azure MCP Server." +title: "[BUGBASH] " +labels: ["needs-triage", "server-Azure.Mcp", "Bug-Bash"] +projects: ["Microsoft/1976"] + +body: + - type: markdown + attributes: + value: "" + - type: textarea + id: background + attributes: + label: Describe the bug + description: Please provide the description of issue you're seeing. + placeholder: Description + validations: + required: true + - type: textarea + id: expected-behavior + attributes: + label: Expected behavior + description: | + Provide a description of the expected behavior. + placeholder: Expected behavior + validations: + required: true + - type: textarea + id: actual-behavior + attributes: + label: Actual behavior + description: | + Provide a description of the actual behavior observed. If applicable please include any error messages, exception stacktraces or memory dumps. + placeholder: Actual behavior + validations: + required: true + - type: textarea + id: repro-steps + attributes: + label: Reproduction Steps + description: | + Please include minimal steps to reproduce the problem if possible. E.g.: the smallest possible code snippet; or a small project, with steps to run it. If possible include text as text rather than screenshots (so it shows up in searches). + placeholder: Minimal Reproduction + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: | + Please provide more information on your environment: + * Hosting platform or OS: [e.g. Azure AppService or Windows 10] + * IDE and version : [e.g. Visual Studio 17.4] + placeholder: Environment + validations: + required: false diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 3d62cd3a6c..1be3f5b164 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -16,11 +16,14 @@ - [ ] For MCP tool changes: - [ ] **One tool per PR**: This PR adds or modifies only one MCP tool for faster review cycles - [ ] Updated `servers/Azure.Mcp.Server/README.md` and/or `servers/Fabric.Mcp.Server/README.md` documentation - - [ ] Updated command list in `/docs/azmcp-commands.md` and/or `/docs/fabric-commands.md` + - [ ] Validate README.md changes using script at `eng/scripts/Process-PackageReadMe.ps1`. See [Package README](https://github.com/microsoft/mcp/blob/main/CONTRIBUTING.md#package-readme) + - [ ] Updated command list in `/servers/Azure.Mcp.Server/docs/azmcp-commands.md` and/or `/docs/fabric-commands.md` + - [ ] Run `.\eng\scripts\Update-AzCommandsMetadata.ps1` to update tool metadata in azmcp-commands.md (required for CI) - [ ] For new or modified tool descriptions, ran [`ToolDescriptionEvaluator`](https://github.com/microsoft/mcp/blob/main/eng/tools/ToolDescriptionEvaluator/Quickstart.md) and obtained a score of `0.4` or more and a top 3 ranking for all related test prompts + - [ ] For tools with new names, including new tools or renamed tools, update [`consolidated-tools.json`](https://github.com/microsoft/mcp/blob/main/core/Azure.Mcp.Core/src/Areas/Server/Resources/consolidated-tools.json) - [ ] For new tools associated with Azure services or publicly available tools/APIs/products, add URL to documentation in the PR description - [ ] Extra steps for **Azure MCP Server** tool changes: - - [ ] Updated test prompts in `/docs/e2eTestPrompts.md` + - [ ] Updated test prompts in `/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md` - [ ] 👉 For Community (non-Microsoft team member) PRs: - [ ] **Security review**: Reviewed code for security vulnerabilities, malicious code, or suspicious activities before running tests (`crypto mining, spam, data exfiltration, etc.`) - [ ] **Manual tests run**: added comment `/azp run mcp - pullrequest - live` to run *Live Test Pipeline* diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 530fb73340..286ed2c65e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -22,3 +22,25 @@ - Include tests that cover your changes - Update CHANGELOG.md with your changes - Run `.\eng\common\spelling\Invoke-Cspell.ps1` +- Create the auto-generated PR body as normal, but `copilot` should add an additional section after all of its regular PR body content. The contents should be: + ``` + ## Invoking Livetests + + Copilot submitted PRs are not trustworthy by default. Users with `write` access to the repo need to validate the contents of this PR before leaving a comment with the text `/azp run mcp - pullrequest - live`. This will trigger the necessary livetest workflows to complete required validation. + ``` + +## Transitioning Live Tests to Recorded Tests + +- Always convert `tool` services to inject `IHttpClientService` into its clients and use `HttpClientService.CreateClient` method to instantiate the `HttpClient` for usage in the tool classes' methods. + - If `IHttpClientService` is already injected into the client, ensure that `HttpClientService.CreateClient` is used to instantiate the `HttpClient`. If this is done, then no further action is needed. +- Always re-parent test classes parented by `CommandTestsBase` to `RecordedCommandTestsBase`. This will require minor fixture adjustments. +- Always generate a new `assets.json` file alongside the livetest csproj file if one does not exist. This file should contain the following content: + ```jsonc + { + "AssetsRepo": "Azure/azure-sdk-assets", + "AssetsRepoPrefixPath": "", + "TagPrefix": "", // e.g., "Azure.Mcp.Tools.KeyVault.LiveTests" + "Tag": "" + } + ``` +- Copilot should utilize the [recorded test documentation](https://github.com/microsoft/mcp/blob/main/docs/recorded-tests.md) in `docs/recorded-tests.md` for more details on how to convert and validate recorded tests. diff --git a/.github/workflows/auto-milestone-bugbash.yml b/.github/workflows/auto-milestone-bugbash.yml new file mode 100644 index 0000000000..5c7b46d6b1 --- /dev/null +++ b/.github/workflows/auto-milestone-bugbash.yml @@ -0,0 +1,50 @@ +name: "Auto-assign milestone for Bug Bash template issues" + +on: + issues: + types: [opened] + +permissions: + issues: write + contents: read + +env: + TARGET_MILESTONE: "2025-10" + +jobs: + set-milestone: + runs-on: ubuntu-latest + if: > + github.event.issue.state == 'open' && + contains(join(github.event.issue.labels.*.name, ','), 'Bug-Bash') && + contains(join(github.event.issue.labels.*.name, ','), 'Azure.Mcp.Server') && + contains(github.event.issue.body, '') + steps: + - name: Apply milestone if missing + uses: actions/github-script@v7 + with: + script: | + const owner = context.repo.owner; + const repo = context.repo.repo; + const issue_number = context.issue.number; + + if (context.payload.issue.milestone) { + core.info('Issue already has a milestone. Skipping.'); + return; + } + + const targetTitle = process.env.TARGET_MILESTONE; + const milestones = await github.paginate( + github.rest.issues.listMilestones, + { owner, repo, state: "open", per_page: 100 } + ); + const ms = milestones.find(m => m.title === targetTitle); + if (!ms) { + core.warning(`Milestone "${targetTitle}" not found. Create it first.`); + return; + } + + await github.rest.issues.update({ + owner, repo, issue_number, milestone: ms.number + }); + core.info(`Milestone "${targetTitle}" applied to #${issue_number}.`); diff --git a/.gitignore b/.gitignore index 97483d294a..5c211d8d82 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +.assets/ +.proxy/ .dist/ .work/ tests/test-cmds.txt @@ -19,3 +21,4 @@ node_modules/ generated/ /docs/commandline +.DS_Store diff --git a/.vscode/cspell.json b/.vscode/cspell.json index ad5e17fe24..c02b2e2e13 100644 --- a/.vscode/cspell.json +++ b/.vscode/cspell.json @@ -33,6 +33,7 @@ "**/bin/**", "**/eng/common/**", "**/obj/**", + "**/tools/**/tests/test-resources-post.ps1", "!**/.devcontainer/**", "**/Fabric.Mcp.Tools.PublicApi/src/Resources/**" ], @@ -61,42 +62,35 @@ { "name": "baseline", "words": [ - "%2Fmcp", - "AADSTS", - "ACCESSTOKEN", - "Commmand", - "Commitish", - "Fmcp", - "Groq", - "HKCU", - "HKEY_CURRENT_USER", - "Hyperscale", - "Intune", - "LASTEXITCODE", - "LPUTF8Str", - "Ollama", - "Roboto", - "Segoe", - "VSTEST", - "Xunit", + "%2fmcp", + "aadsts", "accessibilities", + "accesstoken", "adadmin", "addattachment", "adminprovider", "agentpool", + "amazonq", "apiview", "apphost", + "appinsights", "aspnet", "assemblyfilters", "authenticode", "authkey", "autoscale", + "autoscaler", + "azmk", "azurecr", + "azurepolicy", "azuresdkimages", + "azuresdktrainingdatatme", + "azureuser", "backoff", "backports", "batchfile", "bestpractices", + "blazor", "blobstorage", "blockstorage", "buildid", @@ -105,22 +99,32 @@ "centralus", "classdef", "classfilters", + "cloudevents", "cmds", "cobertura", "codeql", "codesign", + "commitish", + "commmand", + "confidentialledger", + "conig", + "containerd", "contentfiles", "creds", "credscan", "cslschema", + "cust", "cutover", + "datacontent", + "datacontenttype", "datatable", + "datetime", "datistemplate", "datname", - "datetime", "descired", "devcert", "deviceid", + "diagnosticservices", "doubleslash", "dyld", "eastus", @@ -128,28 +132,48 @@ "entra", "entraadmin", "entraid", + "eventstream", "existingaccount", "fabmcp", "filestorage", + "fmcp", "fname", "funkyfoo", "gdnbaselines", "globaltool", "glsl", + "gmsa", + "groq", + "hkcu", + "hkey_current_user", "hotmail", + "hyperscale", + "intellij", + "intune", + "itemdefinition", "kcsb", + "keda", + "kubelet", + "kubeletidentity", + "laskewitz", + "lastexitcode", + "ledger", "libc", "libgcc", "locproj", "logissue", + "lputf8str", "maxdepth", "mcptestadmin", "mgmt", + "microsofticon", "microsoftonline", + "missingdb", "mkdirp", "modelcontextprotocol", "msal", "msrc", + "multistep", "myacr", "mycluster", "mycontainer", @@ -164,32 +188,54 @@ "myuser", "nativeproj", "nettrace", + "newdb", + "newname", + "nodesubnet", "nonexistentaccount", "nonexistentrg", "noninteractive", "notcontains", "notlike", "nslookup", + "nugets", + "olddb", + "ollama", "otel", "otlp", "pipefail", + "podsubnet", "postgresdb", "privatelink", "psscriptanalyzer", "publicapis", + "queryset", "quickstart", "reportgenerator", "reporttypes", - "resx", "resourcegroups", + "resourcename", + "resx", + "rhtest", + "roboto", + "rollups", + "scalesetpriority", + "scriptable", "securestring", + "segoe", + "serverjson", + "setdevversion", "setvariable", "skus", + "sourcebranch", + "specversion", "sqlserver", + "ssword", "storageaccount", "storagev", "structs", "stylecop", + "subdir", + "systempool", "targetdir", "testareas", "testcontainer", @@ -203,36 +249,47 @@ "testshare", "testsub", "toplevel", + "unkown", "uploadsummary", + "userpool", "vectorizable", "vectorizer", + "vmss", "vsix", - "vsixtarget" + "vsixtarget", + "vstest", + "vtpm", + "webtest", + "webtests", + "xunit" ] } ], "words": [ "1espt", "aarch", - "accesspolicy", "acaenvironment", - "ADMINPROVIDER", + "activitylog", + "adminprovider", "agentic", + "aieval", "aisearch", "akscluster", "aksservice", "alcoop", - "AOAI", "amlfs", - "Apim", + "aoai", + "apos", + "apim", "appconfig", "applens", "appservice", - "ASPNETCORE", + "aspnetcore", "australiacentral", "australiaeast", "australiasoutheast", - "Autorenewable", + "autorenewable", + "autorest", "azapi", "azcli", "azext", @@ -250,17 +307,23 @@ "azurebotservice", "azurecacheforredis", "azurecaf", + "azureclicredential", "azurecontainerapp", "azurecosmosdb", "azuredatabaseformysql", "azuredatabaseforpostgresql", + "azuredeveloperclicredential", "azuredocs", "azurefunctions", + "azureicon", "azureisv", "azurekeyvault", + "managedlustre", "azuremanagedlustre", "azuremcp", + "azuremcpserver", "azureopenai", + "azurepowershellcredential", "azureprivateendpoint", "azureresourcegroups", "azureresources", @@ -278,40 +341,45 @@ "azurewebpubsub", "azurewebsites", "backendservice", + "baseresourcename", "bdylan", "bestpractices", "bicepschema", - "BINLOG", + "binlog", "binutils", + "blobupload", "brazilsouth", "brazilsoutheast", "breathability", - "Burstable", - "Byol", + "burstable", + "buildable", + "byol", "canadacentral", "canadaeast", "centralindia", "centralus", + "certificateimport", "chilecentral", "cicd", "cloudarchitect", "codegen", "codeium", - "CODEOWNERS", + "codeowners", "codesign", - "Codespace", + "codespace", "cognitiveservices", "containerapp", "containerapps", - "CONTENTAZUREFILECONNECTIONSTRING", - "CONTENTSHARE", + "contentazurefileconnectionstring", + "contentshare", "contoso", - "CONV", + "conv", "copilotmd", - "Cosell", + "cosell", "csdevkit", "cslschema", "cvzf", + "dataagent", "datalake", "dataplane", "datasource", @@ -319,22 +387,25 @@ "dataverse", "dbforpostgresql", "deallocate", - "DEBUGTELEMETRY", + "debugtelemetry", + "deregistering", "devbox", "devcontainers", "discoverability", - "Distributedtask", - "dotnettools", + "distributedtask", "dotenv", + "dotnettools", "drawcord", - "DUMPFILE", + "dumpfile", "eastasia", "eastus", "eastus2euap", + "elicitations", "enumerables", + "environmentcredential", "eslintcache", "esrp", - "ESRPRELPACMANTEST", + "esrprelpacmantest", "eventgrid", "eventhouse", "exfiltration", @@ -344,18 +415,20 @@ "filefilters", "filesystem", "filesystems", + "flexconsumption", "fnames", "francecentral", "frontendservice", "functionapp", "functionapps", + "functionspremium", "germanynorth", - "gethealth", "grpcio", - "Gsaascend", - "Gsamas", - "GZRS", + "gsaascend", + "gsamas", + "gzrs", "healthmodels", + "headerless", "heatmaps", "hnsw", "hostings", @@ -364,8 +437,8 @@ "idempotency", "idtyp", "indonesiacentral", - "INFILE", - "Intelli", + "infile", + "intelli", "israelcentral", "italynorth", "japaneast", @@ -378,69 +451,79 @@ "keyvault", "koreacentral", "koreasouth", - "Kusto", + "kusto", "kvps", "lakehouse", + "liftr", "ligar", "linkedservices", - "Linq", - "LINUXOS", - "LINUXPOOL", - "LINUXVMIMAGE", - "LLM", + "linq", + "linuxos", + "linuxpool", + "linuxvmimage", + "livetest", + "livetests", + "llm", "loadtest", "loadtesting", "loadtestrun", "loadtests", "lucene", - "MACOS", - "MACPOOL", - "MACVMIMAGE", + "macos", + "macpool", + "macvmimage", "malaysiawest", + "managedidentitycredential", "markitdown", "mcpserver", "mcptmp", "mexicocentral", - "midsole", - "Microbundle", + "microbundle", "microsoftdocs", + "midsole", "monitoredresources", "msal", - "MSRP", + "msrp", "myaccount", "myacr", "myapp", "mycluster", "myfilesystem", "mygroup", - "myworkbook", "mysvc", + "myworkbook", "netstandard", + "newtonsoft", "newzealandnorth", - "Newtonsoft", - "Npgsql", - "nupkg", + "nodepool", + "nodepools", "norequired", "northcentralus", "northeurope", "norwayeast", "norwaywest", + "Npgsql", "npmjs", + "npgsql", + "nupkg", "nuxt", - "Occured", + "occured", "odata", "oidc", "onboarded", "openai", + "openapi", "operationalinsights", - "OUTFILE", + "outfile", + "overridable", "packability", "pageable", "payg", "paygo", + "persistable", "pgrep", - "piechart", "pids", + "piechart", "polandcentral", "portalsettings", "predeploy", @@ -449,21 +532,25 @@ "pscore", "pscustomobject", "pullrequest", + "ragrs", + "ragzrs", "rainfly", - "RAGRS", - "RAGZRS", - "RediSearch", + "redisearch", + "requesturl", "resourcegroup", "resourcegroups", "resourcehealth", + "RESTAPI", "rhvm", - "Runtimes", + "rulesets", + "runtimes", "searchdocs", "serverfarms", "servicebus", "sessionhost", "setparam", "setpermission", + "signalr", "siteextensions", "skillset", "skillsets", @@ -477,8 +564,10 @@ "staticwebapps", "storageaccount", "storageaccounts", - "Streamable", + "streamable", "submode", + "subnetsize", + "subresource", "swedencentral", "swedensouth", "switzerlandnorth", @@ -498,12 +587,13 @@ "timespan", "toolset", "toolsets", + "typespec", "uaenorth", "uksouth", "ukwest", - "UNCOMPRESS", - "UNHEX", - "Upns", + "uncompress", + "unhex", + "upns", "usersession", "vectorizable", "vectorizer", @@ -511,25 +601,26 @@ "versionsuffix", "virtualdesktop", "virtualmachines", - "Vnet", + "visualstudiocodecredential", + "visualstudiocredential", + "vnet", "vscodeignore", "vsmarketplace", "vsts", "vuepress", + "webjobs", "westcentralus", "westeurope", "westus", "westus2", "westus3", + "windowsos", + "windowspool", + "windowsvmimage", "winget", - "WINDOWSOS", - "WINDOWSPOOL", - "WINDOWSVMIMAGE", + "workloadidentitycredential", "wscript", - "xvfb", - "Xunit", - "aieval", - "requesturl", - "dataagent" + "xunit", + "xvfb" ] } diff --git a/.vscode/launch.json b/.vscode/launch.json index 0eeaad7d4c..24b91f3586 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -95,6 +95,36 @@ "DOTNET_ENVIRONMENT": "Development" } }, + { + "name": "Debug Azure Activity logs", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/servers/Azure.Mcp.Server/src/bin/Debug/net9.0/azmcp.exe", + "args": [ + "monitor", + "activitylog", + "list", + "--hours", + "${input:hours}", + "--resource-name", + "${input:resourceName}", + "--resource-type", + "${input:resourceType}", + "--resource-group", + "${input:resourceGroup}", + "--subscription", + "${input:subscription}", + "--top", + "${input:top}" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "stopAtEntry": false, + "env": { + "DOTNET_ENVIRONMENT": "Development" + } + }, { "name": "Debug Azure Data Explorer Databases List with Cluster URI", "type": "coreclr", @@ -422,6 +452,25 @@ "request": "attach", "processId": "${command:pickProcess}" }, + { + "name": "Start MCP Server from Command Line", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build", + "program": "${workspaceFolder}/servers/Azure.Mcp.Server/src/bin/Debug/net9.0/azmcp", + "args": [ + "server", + "start", + "--mode", + "consolidated" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "stopAtEntry": false, + "env": { + "DOTNET_ENVIRONMENT": "Development" + } + }, { "name": "Debug Cosmos Container Items Query", "type": "coreclr", @@ -499,6 +548,30 @@ "env": { "DOTNET_ENVIRONMENT": "Development" } + }, + { + "name": "Debug Azure AI Search knowledge base retrieve", + "type": "coreclr", + "request": "launch", + "program": "${workspaceFolder}/servers/Azure.Mcp.Server/src/bin/Debug/net9.0/azmcp.exe", + "args": [ + "search", + "knowledge", + "base", + "retrieve", + "--service", + "${input:searchService}", + "--knowledge-base", + "${input:knowledgeBase}", + "--query", + "${input:searchRetrieveQuery}" + ], + "cwd": "${workspaceFolder}", + "console": "integratedTerminal", + "stopAtEntry": false, + "env": { + "DOTNET_ENVIRONMENT": "Development" + } } ], "compounds": [ @@ -598,6 +671,24 @@ "type": "promptString", "description": "SQL Database Name", "default": "testdb" + }, + { + "id": "searchService", + "type": "promptString", + "description": "Azure AI Search Service Name", + "default": "" + }, + { + "id": "knowledgeBase", + "type": "promptString", + "description": "Knowledge Base Index Name", + "default": "" + }, + { + "id": "searchRetrieveQuery", + "type": "promptString", + "description": "Knowledge base retrieval query", + "default": "" } ] } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index f393bc9817..6ac719f4a6 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -19,7 +19,7 @@ "type": "process", "args": [ "build", - "${workspaceFolder}/eng/tools/ToolDescriptionEvaluator/ToolDescriptionEvaluator.csproj", + "${workspaceFolder}/eng/tools/ToolDescriptionEvaluator/src/ToolDescriptionEvaluator.csproj", "/property:GenerateFullPaths=true", "/consoleloggerparameters:NoSummary" ], diff --git a/AGENTS.md b/AGENTS.md index f6b87491f3..707a310f44 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -27,6 +27,9 @@ - Prefer file-scoped changes over project-wide modifications when possible - Always review your own code for consistency, maintainability, and testability - Always ask for clarifications if the request is ambiguous or lacks sufficient context +- Write transport-agnostic commands that work in both stdio and HTTP modes +- Keep commands stateless and thread-safe for multi-user remote scenarios +- Test commands with different RBAC permissions for OBO scenarios ## Don't - Use `subscriptionId` parameter name @@ -41,6 +44,11 @@ - Skip error handling or comprehensive tests - Use dashes in command group names (use concatenated lowercase) - Make project-wide changes when file-scoped changes suffice +- Check transport type in commands (stdio vs HTTP) +- Store per-request state in command instance fields +- Access HttpContext directly from commands +- Make transport-specific decisions in command logic +- Assume single-user scenarios when implementing services ## Commands @@ -154,9 +162,9 @@ dotnet build ``` ## API Docs and References -- API documentation: `/docs/azmcp-commands.md` - Complete command reference -- Implementation guide: `/docs/new-command.md` - Step-by-step command creation -- Test prompts: `/docs/e2eTestPrompts.md` - Example prompts for testing +- API documentation: `/servers/Azure.Mcp.Server/docs/azmcp-commands.md` - Complete command reference +- Implementation guide: `/servers/Azure.Mcp.Server/docs/new-command.md` - Step-by-step command creation +- Test prompts: `servers/Azure.Mcp.Server/docs/e2eTestPrompts.md` - Example prompts for testing - Contributing guide: `CONTRIBUTING.md` - Development workflow and standards - Code guidelines: `.github/copilot-instructions.md` - Specific coding standards @@ -171,7 +179,7 @@ dotnet build - Format and type check: `dotnet format && dotnet build` - all green - Unit tests: Add comprehensive tests following existing patterns - Live test infrastructure: Include Bicep template and post-deployment script for Azure services -- Documentation: Update `/docs/azmcp-commands.md` and add test prompts to `/docs/e2eTestPrompts.md` +- Documentation: Update `/servers/Azure.Mcp.Server/docs/azmcp-commands.md` and add test prompts to `/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md` - Tool validation: Run `ToolDescriptionEvaluator` for command descriptions (target: top 3 ranking, ≥0.4 confidence) - Spelling check: `.\eng\common\spelling\Invoke-Cspell.ps1` - Changelog: Update `CHANGELOG.md` with your changes @@ -243,10 +251,10 @@ dotnet build ./eng/scripts/Build-Local.ps1 -BuildNative # Build with debugging symbols -./eng/scripts/Build-Local.ps1 -DebugBuild +./eng/scripts/Build-Local.ps1 # Docker image build -./eng/scripts/Build-Docker.ps1 +./eng/scripts/Build-Docker.ps1 -ServerName "Azure.Mcp.Server" ``` ### Testing Commands @@ -284,8 +292,8 @@ dotnet format --include="tools/Azure.Mcp.Tools.Storage/**/*.cs" ./eng/scripts/Analyze-AOT-Compact.ps1 # Tool description quality validation -pushd 'eng/tools/ToolDescriptionEvaluator' -dotnet run -- --validate --tool-description "Your command description" --prompt "user query" +pushd 'eng/tools/ToolDescriptionEvaluator/src' +dotnet run -- --validate --tool-description "Your command description" --prompt "user query" --test-single-tool 'your-tool-name' popd ``` @@ -418,7 +426,7 @@ catch (Exception ex) ### Base Service Classes Choose the appropriate base class based on operations: -**For Resource Graph queries (recommended):** +**For Azure Resource Read Operations (recommended):** ```csharp public class StorageService(ISubscriptionService subscriptionService, ITenantService tenantService) : BaseAzureResourceService(subscriptionService, tenantService), IStorageService @@ -430,22 +438,32 @@ public class StorageService(ISubscriptionService subscriptionService, ITenantSer resourceGroup, subscription, retryPolicy, - ConvertToStorageAccountModel); + ConvertToStorageAccountModel, + cancellationToken: cancellationToken); } } ``` -**For direct ARM operations:** +**For Azure Resource Write Operations:** ```csharp public class StorageService(ISubscriptionService subscriptionService, ITenantService tenantService) : BaseAzureService(tenantService), IStorageService { private readonly ISubscriptionService _subscriptionService = subscriptionService; - public async Task GetAccountAsync(string subscription, string resourceGroup, string accountName, RetryPolicyOptions? retryPolicy) + public async Task CreateStorageAccount( + string account, + string resourceGroup, + string location, + string subscription, + string? sku = null, + string? accessTier = null, + bool? enableHierarchicalNamespace = null, + string? tenant = null, + RetryPolicyOptions? retryPolicy = null) { var subscriptionResource = await _subscriptionService.GetSubscription(subscription, null, retryPolicy); - // Use subscriptionResource for direct ARM operations + // Use subscriptionResource for write operations } } ``` @@ -455,7 +473,7 @@ public class StorageService(ISubscriptionService subscriptionService, ITenantSer // All response models must be registered for AOT compatibility [JsonSerializable(typeof(StorageAccountGetCommand.StorageAccountListCommandResult))] [JsonSerializable(typeof(StorageAccount))] -[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, WriteIndented = true)] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] internal partial class StorageJsonContext : JsonSerializerContext; // Usage in commands @@ -491,7 +509,7 @@ tools/Azure.Mcp.Tools.{Service}/ ### Tool Description Quality Validation ```powershell # Validate command descriptions for AI agent compatibility -pushd 'eng/tools/ToolDescriptionEvaluator' +pushd 'eng/tools/ToolDescriptionEvaluator/src' # Single prompt validation dotnet run -- --validate --tool-description "Get storage accounts in a subscription" --prompt "show me my storage accounts" @@ -551,14 +569,14 @@ mcp.json configuration for local development: ### Docker Development ```powershell # Build local Docker image -./eng/scripts/Build-Docker.ps1 +./eng/scripts/Build-Docker.ps1 -ServerName "Azure.Mcp.Server" # Use in mcp.json { "servers": { "Azure MCP Server": { "command": "docker", - "args": ["run", "-i", "--rm", "--env-file", "/path/to/.env", "azure/azure-mcp:latest"] + "args": ["run", "-i", "--rm", "--env-file", "/path/to/.env", "azure-sdk/azure-mcp:"] } } } @@ -583,6 +601,63 @@ All new toolsets must be AOT-compatible or excluded from native builds: - Implement `BaseAzureResourceService` for efficient Resource Graph queries - Follow retry policy patterns with `RetryPolicyOptions` +## Remote MCP Server Architecture + +Azure MCP Server supports **stdio** (local) and **HTTP** (remote) transports with different authentication models. + +### Key Differences: Stdio vs Remote HTTP + +| Aspect | Stdio Mode | Remote HTTP Mode | +|--------|-----------|------------------| +| **Concurrency** | Single user | Multiple concurrent users | +| **State Management** | Can use instance fields | Must be stateless | +| **Deployment** | Local binaries | Cloud hosting (App Service, AKS) | +| **Configuration** | Simple (no auth) | Requires Entra ID app registration | + +### Authentication Strategies + +**On-Behalf-Of (OBO) Flow:** +- Per-user authorization with audit trails +- User's RBAC permissions enforced +- Requires API permissions and admin consent +- Command: `--run-as-remote-http-service --outgoing-auth-strategy UseOnBehalfOf` + +**Hosting Environment Identity:** +- Service-level permissions using Managed Identity +- Simpler configuration, no token exchange overhead +- All users share server's permissions +- Command: `--run-as-remote-http-service --outgoing-auth-strategy UseHostingEnvironmentIdentity` + +### Command Implementation for Remote Mode + +**Critical Requirements:** +- Write transport-agnostic commands (work in both stdio and HTTP modes) +- Use `IAzureTokenCredentialProvider` for all Azure authentication +- Keep commands stateless and thread-safe (no instance field state) +- Test with different RBAC permissions for OBO scenarios +- Provide context-aware error messages for remote scenarios + +**Key Patterns:** +```csharp +// ✅ Correct: Authentication provider handles both modes +var credential = await GetCredentialAsync(null, CancellationToken.None); +var armClient = new ArmClient(credential); + +// ❌ Wrong: Don't check transport type or access HttpContext +if (Environment.GetEnvironmentVariable("ASPNETCORE_URLS") != null) { } +var httpContext = _httpContextAccessor.HttpContext; +``` + +### Security Best Practices + +1. Always use HTTPS in production +2. Implement least privilege RBAC +3. Use OBO for multi-tenant scenarios (preserves user identity) +4. Secure configuration secrets with Azure Key Vault +5. Enable Application Insights for monitoring +6. Validate token claims (audience, issuer, scopes) +7. Use Managed Identity when possible + ## External MCP Server Integration The Azure MCP Server can proxy to external MCP servers via `registry.json`: @@ -609,8 +684,8 @@ The Azure MCP Server can proxy to external MCP servers via `registry.json`: ### Required Documentation Updates When adding new commands: -1. **Update `/docs/azmcp-commands.md`** with new command details -2. **Add test prompts to `/docs/e2eTestPrompts.md`** (maintain alphabetical order) +1. **Update `/servers/Azure.Mcp.Server/docs/azmcp-commands.md`** with new command details +2. **Add test prompts to `/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md`** (maintain alphabetical order) 3. **Update toolset README.md** with new functionality 4. **Update CHANGELOG.md** with changes 5. **Add CODEOWNERS entry** for new toolset diff --git a/AzureMcp.sln b/AzureMcp.sln index 14ec1f8c15..66ae3c07e1 100644 --- a/AzureMcp.sln +++ b/AzureMcp.sln @@ -1,4 +1,4 @@ - + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.0.31903.59 @@ -57,6 +57,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{BB85F6EF-A9D EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.AppLens", "tools\Azure.Mcp.Tools.AppLens\src\Azure.Mcp.Tools.AppLens.csproj", "{3D4E5F6A-7B8C-9D0E-1F2A-3B4C5D6E7F8A}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Azure.Mcp.Tools.ApplicationInsights", "Azure.Mcp.Tools.ApplicationInsights", "{E19EB0A0-F682-2D4D-E69C-FF5DD6980CB7}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{EC9869D6-5301-5ADB-CC59-9E37821F5203}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.ApplicationInsights", "tools\Azure.Mcp.Tools.ApplicationInsights\src\Azure.Mcp.Tools.ApplicationInsights.csproj", "{E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Azure.Mcp.Tools.AppService", "Azure.Mcp.Tools.AppService", "{03B49640-8ED6-23C8-1C29-10AEAF3F6973}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{A0C524B1-9E68-5D79-F404-7E079F138486}" @@ -81,11 +87,11 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{AABD2F22-45E EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.AzureIsv", "tools\Azure.Mcp.Tools.AzureIsv\src\Azure.Mcp.Tools.AzureIsv.csproj", "{51FF959C-5452-4611-B3DF-671D5D2FEF1E}" EndProject -Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Azure.Mcp.Tools.AzureManagedLustre", "Azure.Mcp.Tools.AzureManagedLustre", "{9B70BA7E-5CCA-8859-C252-AF72C051F264}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Azure.Mcp.Tools.ManagedLustre", "Azure.Mcp.Tools.ManagedLustre", "{9B70BA7E-5CCA-8859-C252-AF72C051F264}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{72886DEE-EE65-CAB4-D88E-95639D81D7B4}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.AzureManagedLustre", "tools\Azure.Mcp.Tools.AzureManagedLustre\src\Azure.Mcp.Tools.AzureManagedLustre.csproj", "{38EF7AE0-C6B0-4809-85EE-27A2C4F8F83B}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.ManagedLustre", "tools\Azure.Mcp.Tools.ManagedLustre\src\Azure.Mcp.Tools.ManagedLustre.csproj", "{38EF7AE0-C6B0-4809-85EE-27A2C4F8F83B}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Azure.Mcp.Tools.AzureTerraformBestPractices", "Azure.Mcp.Tools.AzureTerraformBestPractices", "{3050869B-5B38-A2E1-E211-FDD96922C0D8}" EndProject @@ -105,6 +111,18 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{7C4CD8CB-4CA EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.CloudArchitect", "tools\Azure.Mcp.Tools.CloudArchitect\src\Azure.Mcp.Tools.CloudArchitect.csproj", "{E77DA2D5-7AFD-43D6-B77A-C22A1167B856}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Azure.Mcp.Tools.Communication", "Azure.Mcp.Tools.Communication", "{C512965D-7BB0-9820-B548-85E6E27E777F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{63ADDEB0-FC34-0EAE-BA79-5E41D9CAA086}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.Communication", "tools\Azure.Mcp.Tools.Communication\src\Azure.Mcp.Tools.Communication.csproj", "{D7B16F40-8636-4EE4-9F9A-77DF5A92EE03}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Azure.Mcp.Tools.ConfidentialLedger", "Azure.Mcp.Tools.ConfidentialLedger", "{59CA5914-CD73-F72D-5AE2-2588F9749673}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{6B5A97A9-D4ED-154B-D06B-95186CD1FF1C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.ConfidentialLedger", "tools\Azure.Mcp.Tools.ConfidentialLedger\src\Azure.Mcp.Tools.ConfidentialLedger.csproj", "{359D3A43-1DFB-4541-AC72-E63EF0D6B3C9}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Azure.Mcp.Tools.Cosmos", "Azure.Mcp.Tools.Cosmos", "{C3450695-99A5-5CF8-F6BD-D2019CEF67AE}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{5DDB903B-9950-F24C-C972-E88A4AADF5AC}" @@ -123,6 +141,12 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{33C33874-E52 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.EventGrid", "tools\Azure.Mcp.Tools.EventGrid\src\Azure.Mcp.Tools.EventGrid.csproj", "{E1234567-1234-1234-1234-123456789012}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Azure.Mcp.Tools.EventHubs", "Azure.Mcp.Tools.EventHubs", "{8CB42B58-89CF-D92F-E189-16F031184C0B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{40DBA547-7429-3933-14A0-AA798EF058C1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.EventHubs", "tools\Azure.Mcp.Tools.EventHubs\src\Azure.Mcp.Tools.EventHubs.csproj", "{632DF404-7E5D-56B7-808D-816A3AB773A4}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Azure.Mcp.Tools.Extension", "Azure.Mcp.Tools.Extension", "{0D319E14-6DDE-C12F-A5B0-76EED797D651}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{917EE497-2381-F55E-88F6-47586FC57241}" @@ -181,10 +205,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Azure.Mcp.Tools.MySql", "Az EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{065991D2-6F8C-6F65-0E72-DC96953B87D8}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.ApplicationInsights", "tools\Azure.Mcp.Tools.ApplicationInsights\src\Azure.Mcp.Tools.ApplicationInsights.csproj", "{E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}" -EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.ApplicationInsights.UnitTests", "tools\Azure.Mcp.Tools.ApplicationInsights\tests\Azure.Mcp.Tools.ApplicationInsights.UnitTests\Azure.Mcp.Tools.ApplicationInsights.UnitTests.csproj", "{C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}" -EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.MySql", "tools\Azure.Mcp.Tools.MySql\src\Azure.Mcp.Tools.MySql.csproj", "{916DF4C9-3EB7-4D2B-B727-6EEEBB049A80}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Azure.Mcp.Tools.Postgres", "Azure.Mcp.Tools.Postgres", "{898C8C6E-FC1C-58E1-FB62-BBE77ED42888}" @@ -223,6 +243,18 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{075C408B-AC7 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.ServiceBus", "tools\Azure.Mcp.Tools.ServiceBus\src\Azure.Mcp.Tools.ServiceBus.csproj", "{F575C6A0-59AF-455D-8B7A-5421D4BAAA2F}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Azure.Mcp.Tools.SignalR", "Azure.Mcp.Tools.SignalR", "{84B02E00-2E23-1547-B733-D86A059C7EE0}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{717ED564-5195-F490-103F-F6C7788C6E34}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.SignalR", "tools\Azure.Mcp.Tools.SignalR\src\Azure.Mcp.Tools.SignalR.csproj", "{9B667E4D-C905-4CA4-B640-0F3B89293CA8}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Azure.Mcp.Tools.Speech", "Azure.Mcp.Tools.Speech", "{AB4067EC-C276-E50A-4A3C-F9DDFC3B9D29}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{7AC89CF8-E7AC-9EE9-D7AA-392DECC2CF04}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.Speech", "tools\Azure.Mcp.Tools.Speech\src\Azure.Mcp.Tools.Speech.csproj", "{C3D4E5F6-7890-ABCD-EF12-345678901234}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Azure.Mcp.Tools.Sql", "Azure.Mcp.Tools.Sql", "{C64ABA23-B3DB-4B75-3019-7CBBB134BB29}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{04A069BA-F670-FF57-4260-3772155E86F2}" @@ -265,7 +297,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "eng", "eng", "{7525B257-249 EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{DAAE2FFB-70A9-DCEF-23A0-0ABAED0A9720}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ToolDescriptionEvaluator", "eng\tools\ToolDescriptionEvaluator\ToolDescriptionEvaluator.csproj", "{1CB18013-ABED-48C1-8576-1E8ABCD70293}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ToolDescriptionEvaluator", "eng\tools\ToolDescriptionEvaluator\src\ToolDescriptionEvaluator.csproj", "{1CB18013-ABED-48C1-8576-1E8ABCD70293}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Fabric.Mcp.Server", "Fabric.Mcp.Server", "{F4EFF172-8E25-E6E4-AFDD-640B2A635C0F}" EndProject @@ -307,6 +339,10 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{6F99F316 EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.AppLens.UnitTests", "tools\Azure.Mcp.Tools.AppLens\tests\Azure.Mcp.Tools.AppLens.UnitTests\Azure.Mcp.Tools.AppLens.UnitTests.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F23456789012}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FA658F27-CA29-C4FC-A436-483AE994B789}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.ApplicationInsights.UnitTests", "tools\Azure.Mcp.Tools.ApplicationInsights\tests\Azure.Mcp.Tools.ApplicationInsights.UnitTests\Azure.Mcp.Tools.ApplicationInsights.UnitTests.csproj", "{C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{047042FC-DF09-5707-F2AE-666AE9547EAC}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.AppService.LiveTests", "tools\Azure.Mcp.Tools.AppService\tests\Azure.Mcp.Tools.AppService.LiveTests\Azure.Mcp.Tools.AppService.LiveTests.csproj", "{10E02570-BC66-4389-AD30-2E19CB9BD700}" @@ -331,9 +367,9 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.AzureIsv.Un EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{D80CC604-369B-AC81-8F7A-C731A7ABD68E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.AzureManagedLustre.LiveTests", "tools\Azure.Mcp.Tools.AzureManagedLustre\tests\Azure.Mcp.Tools.AzureManagedLustre.LiveTests\Azure.Mcp.Tools.AzureManagedLustre.LiveTests.csproj", "{75A5A87C-BF04-4ABE-B466-874074D37C8D}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.ManagedLustre.LiveTests", "tools\Azure.Mcp.Tools.ManagedLustre\tests\Azure.Mcp.Tools.ManagedLustre.LiveTests\Azure.Mcp.Tools.ManagedLustre.LiveTests.csproj", "{75A5A87C-BF04-4ABE-B466-874074D37C8D}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.AzureManagedLustre.UnitTests", "tools\Azure.Mcp.Tools.AzureManagedLustre\tests\Azure.Mcp.Tools.AzureManagedLustre.UnitTests\Azure.Mcp.Tools.AzureManagedLustre.UnitTests.csproj", "{D1BCD822-AC8A-408F-A71A-9205DB29A9D2}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.ManagedLustre.UnitTests", "tools\Azure.Mcp.Tools.ManagedLustre\tests\Azure.Mcp.Tools.ManagedLustre.UnitTests\Azure.Mcp.Tools.ManagedLustre.UnitTests.csproj", "{D1BCD822-AC8A-408F-A71A-9205DB29A9D2}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{A8D0F127-6ACB-380E-8423-B6B6EA1AF2DC}" EndProject @@ -347,6 +383,18 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{D03EF84F EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.CloudArchitect.UnitTests", "tools\Azure.Mcp.Tools.CloudArchitect\tests\Azure.Mcp.Tools.CloudArchitect.UnitTests\Azure.Mcp.Tools.CloudArchitect.UnitTests.csproj", "{2135A527-3910-44B5-A778-C5467D0A6148}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{FCBD2AD5-0A0E-7EFF-287B-AA167C8934A9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.Communication.LiveTests", "tools\Azure.Mcp.Tools.Communication\tests\Azure.Mcp.Tools.Communication.LiveTests\Azure.Mcp.Tools.Communication.LiveTests.csproj", "{02276871-6582-4A21-B629-83527FD90559}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.Communication.UnitTests", "tools\Azure.Mcp.Tools.Communication\tests\Azure.Mcp.Tools.Communication.UnitTests\Azure.Mcp.Tools.Communication.UnitTests.csproj", "{DDF0DC50-766B-4E49-AA46-8D7F2370465A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{6449F0B7-FD03-89F1-ED21-7DE8FE1BCD0A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.ConfidentialLedger.LiveTests", "tools\Azure.Mcp.Tools.ConfidentialLedger\tests\Azure.Mcp.Tools.ConfidentialLedger.LiveTests\Azure.Mcp.Tools.ConfidentialLedger.LiveTests.csproj", "{8F8153F8-6BED-4026-89A5-FEE2FEF7D379}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.ConfidentialLedger.UnitTests", "tools\Azure.Mcp.Tools.ConfidentialLedger\tests\Azure.Mcp.Tools.ConfidentialLedger.UnitTests\Azure.Mcp.Tools.ConfidentialLedger.UnitTests.csproj", "{FD1A0CD2-19BF-443E-9687-83E5B9E438EE}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{399EB16B-AE75-8AFC-99AF-1927C5624042}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.Cosmos.LiveTests", "tools\Azure.Mcp.Tools.Cosmos\tests\Azure.Mcp.Tools.Cosmos.LiveTests\Azure.Mcp.Tools.Cosmos.LiveTests.csproj", "{F3AD707A-5E2D-4F58-BA21-691B4C7D1BA5}" @@ -365,6 +413,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.EventGrid.L EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.EventGrid.UnitTests", "tools\Azure.Mcp.Tools.EventGrid\tests\Azure.Mcp.Tools.EventGrid.UnitTests\Azure.Mcp.Tools.EventGrid.UnitTests.csproj", "{E1234569-1234-1234-1234-1234567890AE}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{DF6A3634-B09B-FAAE-C9A7-03F46588271A}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.EventHubs.LiveTests", "tools\Azure.Mcp.Tools.EventHubs\tests\Azure.Mcp.Tools.EventHubs.LiveTests\Azure.Mcp.Tools.EventHubs.LiveTests.csproj", "{760CAD28-4792-44B8-77D1-0F42F47BD303}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.EventHubs.UnitTests", "tools\Azure.Mcp.Tools.EventHubs\tests\Azure.Mcp.Tools.EventHubs.UnitTests\Azure.Mcp.Tools.EventHubs.UnitTests.csproj", "{459CFD26-F5BC-B3C8-0632-DE7EA1504AD3}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{81E3F68B-68AA-1E51-D1D2-DE3FA9DB90AD}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.Extension.UnitTests", "tools\Azure.Mcp.Tools.Extension\tests\Azure.Mcp.Tools.Extension.UnitTests\Azure.Mcp.Tools.Extension.UnitTests.csproj", "{CE923FCF-F208-4BC5-8C9B-AA54E2930993}" @@ -453,6 +507,18 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.ServiceBus. EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.ServiceBus.UnitTests", "tools\Azure.Mcp.Tools.ServiceBus\tests\Azure.Mcp.Tools.ServiceBus.UnitTests\Azure.Mcp.Tools.ServiceBus.UnitTests.csproj", "{F90633BA-133F-42E3-88D5-EE4DE0FB0586}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{7331D996-A74A-CB9F-1FD5-3D31CD825A37}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.SignalR.LiveTests", "tools\Azure.Mcp.Tools.SignalR\tests\Azure.Mcp.Tools.SignalR.LiveTests\Azure.Mcp.Tools.SignalR.LiveTests.csproj", "{DE85A82A-CC7A-4743-B6FE-7259B152EF26}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.SignalR.UnitTests", "tools\Azure.Mcp.Tools.SignalR\tests\Azure.Mcp.Tools.SignalR.UnitTests\Azure.Mcp.Tools.SignalR.UnitTests.csproj", "{8066FB34-4946-4A49-B3EC-E0E1D28760D1}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{1474C41C-43C3-EE73-1D18-99B5FE232042}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.Speech.LiveTests", "tools\Azure.Mcp.Tools.Speech\tests\Azure.Mcp.Tools.Speech.LiveTests\Azure.Mcp.Tools.Speech.LiveTests.csproj", "{E5F6A7B8-90CD-EF12-3456-7890ABCDEF01}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.Speech.UnitTests", "tools\Azure.Mcp.Tools.Speech\tests\Azure.Mcp.Tools.Speech.UnitTests\Azure.Mcp.Tools.Speech.UnitTests.csproj", "{F6A7B890-CDEF-1234-5678-90ABCDEF0123}" +EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{AE64BB99-52FC-85C8-81F2-9C84ECFCBB2D}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.Sql.LiveTests", "tools\Azure.Mcp.Tools.Sql\tests\Azure.Mcp.Tools.Sql.LiveTests\Azure.Mcp.Tools.Sql.LiveTests.csproj", "{8EAD1CA0-DD5D-493A-A6A4-45DD8E6835EA}" @@ -479,7 +545,22 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.Workbooks.U EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{294AC723-70DA-F50A-2C7A-AC6C0AEA0A62}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fabric.Mcp.Tools.PublicApi.Tests", "tools\Fabric.Mcp.Tools.PublicApi\tests\Fabric.Mcp.Tools.PublicApi.Tests.csproj", "{D3F46C2D-3AFD-FD9C-9C6A-180B1514DD2F}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Fabric.Mcp.Tools.PublicApi.UnitTests", "tools\Fabric.Mcp.Tools.PublicApi\tests\Fabric.Mcp.Tools.PublicApi.UnitTests\Fabric.Mcp.Tools.PublicApi.UnitTests.csproj", "{D3F46C2D-3AFD-FD9C-9C6A-180B1514DD2F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.Postgres.LiveTests", "tools\Azure.Mcp.Tools.Postgres\tests\Azure.Mcp.Tools.Postgres.LiveTests\Azure.Mcp.Tools.Postgres.LiveTests.csproj", "{BF0354AE-3748-A8DC-F79D-B21FDDEDDFAE}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Azure.Mcp.Tools.AzureAIBestPractices", "Azure.Mcp.Tools.AzureAIBestPractices", "{156D9C17-61FD-98D6-32C0-065B406D0434}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{5D760DD8-DBA3-B865-9021-FDE8FD3497A8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.AzureAIBestPractices", "tools\Azure.Mcp.Tools.AzureAIBestPractices\src\Azure.Mcp.Tools.AzureAIBestPractices.csproj", "{87C51120-6A0A-4D14-B644-1787DB6C6D6E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{50124EEC-97B0-320E-80D4-8464D7692B22}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Tools.AzureAIBestPractices.UnitTests", "tools\Azure.Mcp.Tools.AzureAIBestPractices\tests\Azure.Mcp.Tools.AzureAIBestPractices.UnitTests\Azure.Mcp.Tools.AzureAIBestPractices.UnitTests.csproj", "{BE8CFF4C-E536-43DB-9D01-001E9A052D37}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{319B94CD-694C-16E8-9E3A-9577B99158DD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.Mcp.Server.UnitTests", "servers\Azure.Mcp.Server\tests\Azure.Mcp.Server.UnitTests\Azure.Mcp.Server.UnitTests.csproj", "{ADF14627-FCB5-4BD3-B65F-DDCC3A3F727C}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -599,6 +680,18 @@ Global {3D4E5F6A-7B8C-9D0E-1F2A-3B4C5D6E7F8A}.Release|x64.Build.0 = Release|Any CPU {3D4E5F6A-7B8C-9D0E-1F2A-3B4C5D6E7F8A}.Release|x86.ActiveCfg = Release|Any CPU {3D4E5F6A-7B8C-9D0E-1F2A-3B4C5D6E7F8A}.Release|x86.Build.0 = Release|Any CPU + {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}.Debug|x64.ActiveCfg = Debug|Any CPU + {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}.Debug|x64.Build.0 = Debug|Any CPU + {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}.Debug|x86.ActiveCfg = Debug|Any CPU + {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}.Debug|x86.Build.0 = Debug|Any CPU + {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}.Release|Any CPU.Build.0 = Release|Any CPU + {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}.Release|x64.ActiveCfg = Release|Any CPU + {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}.Release|x64.Build.0 = Release|Any CPU + {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}.Release|x86.ActiveCfg = Release|Any CPU + {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}.Release|x86.Build.0 = Release|Any CPU {7828AB14-5E8B-4E60-B8AA-988E70573684}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7828AB14-5E8B-4E60-B8AA-988E70573684}.Debug|Any CPU.Build.0 = Debug|Any CPU {7828AB14-5E8B-4E60-B8AA-988E70573684}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -695,6 +788,30 @@ Global {E77DA2D5-7AFD-43D6-B77A-C22A1167B856}.Release|x64.Build.0 = Release|Any CPU {E77DA2D5-7AFD-43D6-B77A-C22A1167B856}.Release|x86.ActiveCfg = Release|Any CPU {E77DA2D5-7AFD-43D6-B77A-C22A1167B856}.Release|x86.Build.0 = Release|Any CPU + {D7B16F40-8636-4EE4-9F9A-77DF5A92EE03}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D7B16F40-8636-4EE4-9F9A-77DF5A92EE03}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D7B16F40-8636-4EE4-9F9A-77DF5A92EE03}.Debug|x64.ActiveCfg = Debug|Any CPU + {D7B16F40-8636-4EE4-9F9A-77DF5A92EE03}.Debug|x64.Build.0 = Debug|Any CPU + {D7B16F40-8636-4EE4-9F9A-77DF5A92EE03}.Debug|x86.ActiveCfg = Debug|Any CPU + {D7B16F40-8636-4EE4-9F9A-77DF5A92EE03}.Debug|x86.Build.0 = Debug|Any CPU + {D7B16F40-8636-4EE4-9F9A-77DF5A92EE03}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D7B16F40-8636-4EE4-9F9A-77DF5A92EE03}.Release|Any CPU.Build.0 = Release|Any CPU + {D7B16F40-8636-4EE4-9F9A-77DF5A92EE03}.Release|x64.ActiveCfg = Release|Any CPU + {D7B16F40-8636-4EE4-9F9A-77DF5A92EE03}.Release|x64.Build.0 = Release|Any CPU + {D7B16F40-8636-4EE4-9F9A-77DF5A92EE03}.Release|x86.ActiveCfg = Release|Any CPU + {D7B16F40-8636-4EE4-9F9A-77DF5A92EE03}.Release|x86.Build.0 = Release|Any CPU + {359D3A43-1DFB-4541-AC72-E63EF0D6B3C9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {359D3A43-1DFB-4541-AC72-E63EF0D6B3C9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {359D3A43-1DFB-4541-AC72-E63EF0D6B3C9}.Debug|x64.ActiveCfg = Debug|Any CPU + {359D3A43-1DFB-4541-AC72-E63EF0D6B3C9}.Debug|x64.Build.0 = Debug|Any CPU + {359D3A43-1DFB-4541-AC72-E63EF0D6B3C9}.Debug|x86.ActiveCfg = Debug|Any CPU + {359D3A43-1DFB-4541-AC72-E63EF0D6B3C9}.Debug|x86.Build.0 = Debug|Any CPU + {359D3A43-1DFB-4541-AC72-E63EF0D6B3C9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {359D3A43-1DFB-4541-AC72-E63EF0D6B3C9}.Release|Any CPU.Build.0 = Release|Any CPU + {359D3A43-1DFB-4541-AC72-E63EF0D6B3C9}.Release|x64.ActiveCfg = Release|Any CPU + {359D3A43-1DFB-4541-AC72-E63EF0D6B3C9}.Release|x64.Build.0 = Release|Any CPU + {359D3A43-1DFB-4541-AC72-E63EF0D6B3C9}.Release|x86.ActiveCfg = Release|Any CPU + {359D3A43-1DFB-4541-AC72-E63EF0D6B3C9}.Release|x86.Build.0 = Release|Any CPU {C8612FFE-7E8F-4EC4-B158-A4C2167A9AD5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C8612FFE-7E8F-4EC4-B158-A4C2167A9AD5}.Debug|Any CPU.Build.0 = Debug|Any CPU {C8612FFE-7E8F-4EC4-B158-A4C2167A9AD5}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -731,6 +848,18 @@ Global {E1234567-1234-1234-1234-123456789012}.Release|x64.Build.0 = Release|Any CPU {E1234567-1234-1234-1234-123456789012}.Release|x86.ActiveCfg = Release|Any CPU {E1234567-1234-1234-1234-123456789012}.Release|x86.Build.0 = Release|Any CPU + {632DF404-7E5D-56B7-808D-816A3AB773A4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {632DF404-7E5D-56B7-808D-816A3AB773A4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {632DF404-7E5D-56B7-808D-816A3AB773A4}.Debug|x64.ActiveCfg = Debug|Any CPU + {632DF404-7E5D-56B7-808D-816A3AB773A4}.Debug|x64.Build.0 = Debug|Any CPU + {632DF404-7E5D-56B7-808D-816A3AB773A4}.Debug|x86.ActiveCfg = Debug|Any CPU + {632DF404-7E5D-56B7-808D-816A3AB773A4}.Debug|x86.Build.0 = Debug|Any CPU + {632DF404-7E5D-56B7-808D-816A3AB773A4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {632DF404-7E5D-56B7-808D-816A3AB773A4}.Release|Any CPU.Build.0 = Release|Any CPU + {632DF404-7E5D-56B7-808D-816A3AB773A4}.Release|x64.ActiveCfg = Release|Any CPU + {632DF404-7E5D-56B7-808D-816A3AB773A4}.Release|x64.Build.0 = Release|Any CPU + {632DF404-7E5D-56B7-808D-816A3AB773A4}.Release|x86.ActiveCfg = Release|Any CPU + {632DF404-7E5D-56B7-808D-816A3AB773A4}.Release|x86.Build.0 = Release|Any CPU {2DF5CFE5-EF8D-4BE4-96D0-C256F2B52011}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2DF5CFE5-EF8D-4BE4-96D0-C256F2B52011}.Debug|Any CPU.Build.0 = Debug|Any CPU {2DF5CFE5-EF8D-4BE4-96D0-C256F2B52011}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -839,18 +968,6 @@ Global {168BAD1F-6D9B-4A41-9A40-CAA2766110EF}.Release|x64.Build.0 = Release|Any CPU {168BAD1F-6D9B-4A41-9A40-CAA2766110EF}.Release|x86.ActiveCfg = Release|Any CPU {168BAD1F-6D9B-4A41-9A40-CAA2766110EF}.Release|x86.Build.0 = Release|Any CPU - {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}.Debug|x64.ActiveCfg = Debug|Any CPU - {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}.Debug|x64.Build.0 = Debug|Any CPU - {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}.Debug|x86.ActiveCfg = Debug|Any CPU - {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}.Debug|x86.Build.0 = Debug|Any CPU - {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}.Release|Any CPU.Build.0 = Release|Any CPU - {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}.Release|x64.ActiveCfg = Release|Any CPU - {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}.Release|x64.Build.0 = Release|Any CPU - {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}.Release|x86.ActiveCfg = Release|Any CPU - {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB}.Release|x86.Build.0 = Release|Any CPU {916DF4C9-3EB7-4D2B-B727-6EEEBB049A80}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {916DF4C9-3EB7-4D2B-B727-6EEEBB049A80}.Debug|Any CPU.Build.0 = Debug|Any CPU {916DF4C9-3EB7-4D2B-B727-6EEEBB049A80}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -935,6 +1052,30 @@ Global {F575C6A0-59AF-455D-8B7A-5421D4BAAA2F}.Release|x64.Build.0 = Release|Any CPU {F575C6A0-59AF-455D-8B7A-5421D4BAAA2F}.Release|x86.ActiveCfg = Release|Any CPU {F575C6A0-59AF-455D-8B7A-5421D4BAAA2F}.Release|x86.Build.0 = Release|Any CPU + {9B667E4D-C905-4CA4-B640-0F3B89293CA8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9B667E4D-C905-4CA4-B640-0F3B89293CA8}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9B667E4D-C905-4CA4-B640-0F3B89293CA8}.Debug|x64.ActiveCfg = Debug|Any CPU + {9B667E4D-C905-4CA4-B640-0F3B89293CA8}.Debug|x64.Build.0 = Debug|Any CPU + {9B667E4D-C905-4CA4-B640-0F3B89293CA8}.Debug|x86.ActiveCfg = Debug|Any CPU + {9B667E4D-C905-4CA4-B640-0F3B89293CA8}.Debug|x86.Build.0 = Debug|Any CPU + {9B667E4D-C905-4CA4-B640-0F3B89293CA8}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9B667E4D-C905-4CA4-B640-0F3B89293CA8}.Release|Any CPU.Build.0 = Release|Any CPU + {9B667E4D-C905-4CA4-B640-0F3B89293CA8}.Release|x64.ActiveCfg = Release|Any CPU + {9B667E4D-C905-4CA4-B640-0F3B89293CA8}.Release|x64.Build.0 = Release|Any CPU + {9B667E4D-C905-4CA4-B640-0F3B89293CA8}.Release|x86.ActiveCfg = Release|Any CPU + {9B667E4D-C905-4CA4-B640-0F3B89293CA8}.Release|x86.Build.0 = Release|Any CPU + {C3D4E5F6-7890-ABCD-EF12-345678901234}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C3D4E5F6-7890-ABCD-EF12-345678901234}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C3D4E5F6-7890-ABCD-EF12-345678901234}.Debug|x64.ActiveCfg = Debug|Any CPU + {C3D4E5F6-7890-ABCD-EF12-345678901234}.Debug|x64.Build.0 = Debug|Any CPU + {C3D4E5F6-7890-ABCD-EF12-345678901234}.Debug|x86.ActiveCfg = Debug|Any CPU + {C3D4E5F6-7890-ABCD-EF12-345678901234}.Debug|x86.Build.0 = Debug|Any CPU + {C3D4E5F6-7890-ABCD-EF12-345678901234}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C3D4E5F6-7890-ABCD-EF12-345678901234}.Release|Any CPU.Build.0 = Release|Any CPU + {C3D4E5F6-7890-ABCD-EF12-345678901234}.Release|x64.ActiveCfg = Release|Any CPU + {C3D4E5F6-7890-ABCD-EF12-345678901234}.Release|x64.Build.0 = Release|Any CPU + {C3D4E5F6-7890-ABCD-EF12-345678901234}.Release|x86.ActiveCfg = Release|Any CPU + {C3D4E5F6-7890-ABCD-EF12-345678901234}.Release|x86.Build.0 = Release|Any CPU {C84A54B1-BFF9-4394-A6BF-135C2FC37A64}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C84A54B1-BFF9-4394-A6BF-135C2FC37A64}.Debug|Any CPU.Build.0 = Debug|Any CPU {C84A54B1-BFF9-4394-A6BF-135C2FC37A64}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -1151,6 +1292,18 @@ Global {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Release|x64.Build.0 = Release|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Release|x86.ActiveCfg = Release|Any CPU {B2C3D4E5-F6A7-8901-BCDE-F23456789012}.Release|x86.Build.0 = Release|Any CPU + {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}.Debug|x64.ActiveCfg = Debug|Any CPU + {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}.Debug|x64.Build.0 = Debug|Any CPU + {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}.Debug|x86.ActiveCfg = Debug|Any CPU + {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}.Debug|x86.Build.0 = Debug|Any CPU + {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}.Release|Any CPU.Build.0 = Release|Any CPU + {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}.Release|x64.ActiveCfg = Release|Any CPU + {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}.Release|x64.Build.0 = Release|Any CPU + {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}.Release|x86.ActiveCfg = Release|Any CPU + {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}.Release|x86.Build.0 = Release|Any CPU {10E02570-BC66-4389-AD30-2E19CB9BD700}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {10E02570-BC66-4389-AD30-2E19CB9BD700}.Debug|Any CPU.Build.0 = Debug|Any CPU {10E02570-BC66-4389-AD30-2E19CB9BD700}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -1295,6 +1448,54 @@ Global {2135A527-3910-44B5-A778-C5467D0A6148}.Release|x64.Build.0 = Release|Any CPU {2135A527-3910-44B5-A778-C5467D0A6148}.Release|x86.ActiveCfg = Release|Any CPU {2135A527-3910-44B5-A778-C5467D0A6148}.Release|x86.Build.0 = Release|Any CPU + {02276871-6582-4A21-B629-83527FD90559}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {02276871-6582-4A21-B629-83527FD90559}.Debug|Any CPU.Build.0 = Debug|Any CPU + {02276871-6582-4A21-B629-83527FD90559}.Debug|x64.ActiveCfg = Debug|Any CPU + {02276871-6582-4A21-B629-83527FD90559}.Debug|x64.Build.0 = Debug|Any CPU + {02276871-6582-4A21-B629-83527FD90559}.Debug|x86.ActiveCfg = Debug|Any CPU + {02276871-6582-4A21-B629-83527FD90559}.Debug|x86.Build.0 = Debug|Any CPU + {02276871-6582-4A21-B629-83527FD90559}.Release|Any CPU.ActiveCfg = Release|Any CPU + {02276871-6582-4A21-B629-83527FD90559}.Release|Any CPU.Build.0 = Release|Any CPU + {02276871-6582-4A21-B629-83527FD90559}.Release|x64.ActiveCfg = Release|Any CPU + {02276871-6582-4A21-B629-83527FD90559}.Release|x64.Build.0 = Release|Any CPU + {02276871-6582-4A21-B629-83527FD90559}.Release|x86.ActiveCfg = Release|Any CPU + {02276871-6582-4A21-B629-83527FD90559}.Release|x86.Build.0 = Release|Any CPU + {DDF0DC50-766B-4E49-AA46-8D7F2370465A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DDF0DC50-766B-4E49-AA46-8D7F2370465A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DDF0DC50-766B-4E49-AA46-8D7F2370465A}.Debug|x64.ActiveCfg = Debug|Any CPU + {DDF0DC50-766B-4E49-AA46-8D7F2370465A}.Debug|x64.Build.0 = Debug|Any CPU + {DDF0DC50-766B-4E49-AA46-8D7F2370465A}.Debug|x86.ActiveCfg = Debug|Any CPU + {DDF0DC50-766B-4E49-AA46-8D7F2370465A}.Debug|x86.Build.0 = Debug|Any CPU + {DDF0DC50-766B-4E49-AA46-8D7F2370465A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DDF0DC50-766B-4E49-AA46-8D7F2370465A}.Release|Any CPU.Build.0 = Release|Any CPU + {DDF0DC50-766B-4E49-AA46-8D7F2370465A}.Release|x64.ActiveCfg = Release|Any CPU + {DDF0DC50-766B-4E49-AA46-8D7F2370465A}.Release|x64.Build.0 = Release|Any CPU + {DDF0DC50-766B-4E49-AA46-8D7F2370465A}.Release|x86.ActiveCfg = Release|Any CPU + {DDF0DC50-766B-4E49-AA46-8D7F2370465A}.Release|x86.Build.0 = Release|Any CPU + {8F8153F8-6BED-4026-89A5-FEE2FEF7D379}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F8153F8-6BED-4026-89A5-FEE2FEF7D379}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F8153F8-6BED-4026-89A5-FEE2FEF7D379}.Debug|x64.ActiveCfg = Debug|Any CPU + {8F8153F8-6BED-4026-89A5-FEE2FEF7D379}.Debug|x64.Build.0 = Debug|Any CPU + {8F8153F8-6BED-4026-89A5-FEE2FEF7D379}.Debug|x86.ActiveCfg = Debug|Any CPU + {8F8153F8-6BED-4026-89A5-FEE2FEF7D379}.Debug|x86.Build.0 = Debug|Any CPU + {8F8153F8-6BED-4026-89A5-FEE2FEF7D379}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F8153F8-6BED-4026-89A5-FEE2FEF7D379}.Release|Any CPU.Build.0 = Release|Any CPU + {8F8153F8-6BED-4026-89A5-FEE2FEF7D379}.Release|x64.ActiveCfg = Release|Any CPU + {8F8153F8-6BED-4026-89A5-FEE2FEF7D379}.Release|x64.Build.0 = Release|Any CPU + {8F8153F8-6BED-4026-89A5-FEE2FEF7D379}.Release|x86.ActiveCfg = Release|Any CPU + {8F8153F8-6BED-4026-89A5-FEE2FEF7D379}.Release|x86.Build.0 = Release|Any CPU + {FD1A0CD2-19BF-443E-9687-83E5B9E438EE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FD1A0CD2-19BF-443E-9687-83E5B9E438EE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FD1A0CD2-19BF-443E-9687-83E5B9E438EE}.Debug|x64.ActiveCfg = Debug|Any CPU + {FD1A0CD2-19BF-443E-9687-83E5B9E438EE}.Debug|x64.Build.0 = Debug|Any CPU + {FD1A0CD2-19BF-443E-9687-83E5B9E438EE}.Debug|x86.ActiveCfg = Debug|Any CPU + {FD1A0CD2-19BF-443E-9687-83E5B9E438EE}.Debug|x86.Build.0 = Debug|Any CPU + {FD1A0CD2-19BF-443E-9687-83E5B9E438EE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FD1A0CD2-19BF-443E-9687-83E5B9E438EE}.Release|Any CPU.Build.0 = Release|Any CPU + {FD1A0CD2-19BF-443E-9687-83E5B9E438EE}.Release|x64.ActiveCfg = Release|Any CPU + {FD1A0CD2-19BF-443E-9687-83E5B9E438EE}.Release|x64.Build.0 = Release|Any CPU + {FD1A0CD2-19BF-443E-9687-83E5B9E438EE}.Release|x86.ActiveCfg = Release|Any CPU + {FD1A0CD2-19BF-443E-9687-83E5B9E438EE}.Release|x86.Build.0 = Release|Any CPU {F3AD707A-5E2D-4F58-BA21-691B4C7D1BA5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {F3AD707A-5E2D-4F58-BA21-691B4C7D1BA5}.Debug|Any CPU.Build.0 = Debug|Any CPU {F3AD707A-5E2D-4F58-BA21-691B4C7D1BA5}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -1367,6 +1568,30 @@ Global {E1234569-1234-1234-1234-1234567890AE}.Release|x64.Build.0 = Release|Any CPU {E1234569-1234-1234-1234-1234567890AE}.Release|x86.ActiveCfg = Release|Any CPU {E1234569-1234-1234-1234-1234567890AE}.Release|x86.Build.0 = Release|Any CPU + {760CAD28-4792-44B8-77D1-0F42F47BD303}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {760CAD28-4792-44B8-77D1-0F42F47BD303}.Debug|Any CPU.Build.0 = Debug|Any CPU + {760CAD28-4792-44B8-77D1-0F42F47BD303}.Debug|x64.ActiveCfg = Debug|Any CPU + {760CAD28-4792-44B8-77D1-0F42F47BD303}.Debug|x64.Build.0 = Debug|Any CPU + {760CAD28-4792-44B8-77D1-0F42F47BD303}.Debug|x86.ActiveCfg = Debug|Any CPU + {760CAD28-4792-44B8-77D1-0F42F47BD303}.Debug|x86.Build.0 = Debug|Any CPU + {760CAD28-4792-44B8-77D1-0F42F47BD303}.Release|Any CPU.ActiveCfg = Release|Any CPU + {760CAD28-4792-44B8-77D1-0F42F47BD303}.Release|Any CPU.Build.0 = Release|Any CPU + {760CAD28-4792-44B8-77D1-0F42F47BD303}.Release|x64.ActiveCfg = Release|Any CPU + {760CAD28-4792-44B8-77D1-0F42F47BD303}.Release|x64.Build.0 = Release|Any CPU + {760CAD28-4792-44B8-77D1-0F42F47BD303}.Release|x86.ActiveCfg = Release|Any CPU + {760CAD28-4792-44B8-77D1-0F42F47BD303}.Release|x86.Build.0 = Release|Any CPU + {459CFD26-F5BC-B3C8-0632-DE7EA1504AD3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {459CFD26-F5BC-B3C8-0632-DE7EA1504AD3}.Debug|Any CPU.Build.0 = Debug|Any CPU + {459CFD26-F5BC-B3C8-0632-DE7EA1504AD3}.Debug|x64.ActiveCfg = Debug|Any CPU + {459CFD26-F5BC-B3C8-0632-DE7EA1504AD3}.Debug|x64.Build.0 = Debug|Any CPU + {459CFD26-F5BC-B3C8-0632-DE7EA1504AD3}.Debug|x86.ActiveCfg = Debug|Any CPU + {459CFD26-F5BC-B3C8-0632-DE7EA1504AD3}.Debug|x86.Build.0 = Debug|Any CPU + {459CFD26-F5BC-B3C8-0632-DE7EA1504AD3}.Release|Any CPU.ActiveCfg = Release|Any CPU + {459CFD26-F5BC-B3C8-0632-DE7EA1504AD3}.Release|Any CPU.Build.0 = Release|Any CPU + {459CFD26-F5BC-B3C8-0632-DE7EA1504AD3}.Release|x64.ActiveCfg = Release|Any CPU + {459CFD26-F5BC-B3C8-0632-DE7EA1504AD3}.Release|x64.Build.0 = Release|Any CPU + {459CFD26-F5BC-B3C8-0632-DE7EA1504AD3}.Release|x86.ActiveCfg = Release|Any CPU + {459CFD26-F5BC-B3C8-0632-DE7EA1504AD3}.Release|x86.Build.0 = Release|Any CPU {CE923FCF-F208-4BC5-8C9B-AA54E2930993}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {CE923FCF-F208-4BC5-8C9B-AA54E2930993}.Debug|Any CPU.Build.0 = Debug|Any CPU {CE923FCF-F208-4BC5-8C9B-AA54E2930993}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -1703,6 +1928,54 @@ Global {F90633BA-133F-42E3-88D5-EE4DE0FB0586}.Release|x64.Build.0 = Release|Any CPU {F90633BA-133F-42E3-88D5-EE4DE0FB0586}.Release|x86.ActiveCfg = Release|Any CPU {F90633BA-133F-42E3-88D5-EE4DE0FB0586}.Release|x86.Build.0 = Release|Any CPU + {DE85A82A-CC7A-4743-B6FE-7259B152EF26}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DE85A82A-CC7A-4743-B6FE-7259B152EF26}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DE85A82A-CC7A-4743-B6FE-7259B152EF26}.Debug|x64.ActiveCfg = Debug|Any CPU + {DE85A82A-CC7A-4743-B6FE-7259B152EF26}.Debug|x64.Build.0 = Debug|Any CPU + {DE85A82A-CC7A-4743-B6FE-7259B152EF26}.Debug|x86.ActiveCfg = Debug|Any CPU + {DE85A82A-CC7A-4743-B6FE-7259B152EF26}.Debug|x86.Build.0 = Debug|Any CPU + {DE85A82A-CC7A-4743-B6FE-7259B152EF26}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DE85A82A-CC7A-4743-B6FE-7259B152EF26}.Release|Any CPU.Build.0 = Release|Any CPU + {DE85A82A-CC7A-4743-B6FE-7259B152EF26}.Release|x64.ActiveCfg = Release|Any CPU + {DE85A82A-CC7A-4743-B6FE-7259B152EF26}.Release|x64.Build.0 = Release|Any CPU + {DE85A82A-CC7A-4743-B6FE-7259B152EF26}.Release|x86.ActiveCfg = Release|Any CPU + {DE85A82A-CC7A-4743-B6FE-7259B152EF26}.Release|x86.Build.0 = Release|Any CPU + {8066FB34-4946-4A49-B3EC-E0E1D28760D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8066FB34-4946-4A49-B3EC-E0E1D28760D1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8066FB34-4946-4A49-B3EC-E0E1D28760D1}.Debug|x64.ActiveCfg = Debug|Any CPU + {8066FB34-4946-4A49-B3EC-E0E1D28760D1}.Debug|x64.Build.0 = Debug|Any CPU + {8066FB34-4946-4A49-B3EC-E0E1D28760D1}.Debug|x86.ActiveCfg = Debug|Any CPU + {8066FB34-4946-4A49-B3EC-E0E1D28760D1}.Debug|x86.Build.0 = Debug|Any CPU + {8066FB34-4946-4A49-B3EC-E0E1D28760D1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8066FB34-4946-4A49-B3EC-E0E1D28760D1}.Release|Any CPU.Build.0 = Release|Any CPU + {8066FB34-4946-4A49-B3EC-E0E1D28760D1}.Release|x64.ActiveCfg = Release|Any CPU + {8066FB34-4946-4A49-B3EC-E0E1D28760D1}.Release|x64.Build.0 = Release|Any CPU + {8066FB34-4946-4A49-B3EC-E0E1D28760D1}.Release|x86.ActiveCfg = Release|Any CPU + {8066FB34-4946-4A49-B3EC-E0E1D28760D1}.Release|x86.Build.0 = Release|Any CPU + {E5F6A7B8-90CD-EF12-3456-7890ABCDEF01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E5F6A7B8-90CD-EF12-3456-7890ABCDEF01}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E5F6A7B8-90CD-EF12-3456-7890ABCDEF01}.Debug|x64.ActiveCfg = Debug|Any CPU + {E5F6A7B8-90CD-EF12-3456-7890ABCDEF01}.Debug|x64.Build.0 = Debug|Any CPU + {E5F6A7B8-90CD-EF12-3456-7890ABCDEF01}.Debug|x86.ActiveCfg = Debug|Any CPU + {E5F6A7B8-90CD-EF12-3456-7890ABCDEF01}.Debug|x86.Build.0 = Debug|Any CPU + {E5F6A7B8-90CD-EF12-3456-7890ABCDEF01}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E5F6A7B8-90CD-EF12-3456-7890ABCDEF01}.Release|Any CPU.Build.0 = Release|Any CPU + {E5F6A7B8-90CD-EF12-3456-7890ABCDEF01}.Release|x64.ActiveCfg = Release|Any CPU + {E5F6A7B8-90CD-EF12-3456-7890ABCDEF01}.Release|x64.Build.0 = Release|Any CPU + {E5F6A7B8-90CD-EF12-3456-7890ABCDEF01}.Release|x86.ActiveCfg = Release|Any CPU + {E5F6A7B8-90CD-EF12-3456-7890ABCDEF01}.Release|x86.Build.0 = Release|Any CPU + {F6A7B890-CDEF-1234-5678-90ABCDEF0123}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F6A7B890-CDEF-1234-5678-90ABCDEF0123}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F6A7B890-CDEF-1234-5678-90ABCDEF0123}.Debug|x64.ActiveCfg = Debug|Any CPU + {F6A7B890-CDEF-1234-5678-90ABCDEF0123}.Debug|x64.Build.0 = Debug|Any CPU + {F6A7B890-CDEF-1234-5678-90ABCDEF0123}.Debug|x86.ActiveCfg = Debug|Any CPU + {F6A7B890-CDEF-1234-5678-90ABCDEF0123}.Debug|x86.Build.0 = Debug|Any CPU + {F6A7B890-CDEF-1234-5678-90ABCDEF0123}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F6A7B890-CDEF-1234-5678-90ABCDEF0123}.Release|Any CPU.Build.0 = Release|Any CPU + {F6A7B890-CDEF-1234-5678-90ABCDEF0123}.Release|x64.ActiveCfg = Release|Any CPU + {F6A7B890-CDEF-1234-5678-90ABCDEF0123}.Release|x64.Build.0 = Release|Any CPU + {F6A7B890-CDEF-1234-5678-90ABCDEF0123}.Release|x86.ActiveCfg = Release|Any CPU + {F6A7B890-CDEF-1234-5678-90ABCDEF0123}.Release|x86.Build.0 = Release|Any CPU {8EAD1CA0-DD5D-493A-A6A4-45DD8E6835EA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {8EAD1CA0-DD5D-493A-A6A4-45DD8E6835EA}.Debug|Any CPU.Build.0 = Debug|Any CPU {8EAD1CA0-DD5D-493A-A6A4-45DD8E6835EA}.Debug|x64.ActiveCfg = Debug|Any CPU @@ -1811,18 +2084,54 @@ Global {D3F46C2D-3AFD-FD9C-9C6A-180B1514DD2F}.Release|x64.Build.0 = Release|Any CPU {D3F46C2D-3AFD-FD9C-9C6A-180B1514DD2F}.Release|x86.ActiveCfg = Release|Any CPU {D3F46C2D-3AFD-FD9C-9C6A-180B1514DD2F}.Release|x86.Build.0 = Release|Any CPU - {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}.Debug|Any CPU.Build.0 = Debug|Any CPU - {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}.Debug|x64.ActiveCfg = Debug|Any CPU - {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}.Debug|x64.Build.0 = Debug|Any CPU - {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}.Debug|x86.ActiveCfg = Debug|Any CPU - {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}.Debug|x86.Build.0 = Debug|Any CPU - {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}.Release|Any CPU.ActiveCfg = Release|Any CPU - {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}.Release|Any CPU.Build.0 = Release|Any CPU - {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}.Release|x64.ActiveCfg = Release|Any CPU - {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}.Release|x64.Build.0 = Release|Any CPU - {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}.Release|x86.ActiveCfg = Release|Any CPU - {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11}.Release|x86.Build.0 = Release|Any CPU + {BF0354AE-3748-A8DC-F79D-B21FDDEDDFAE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BF0354AE-3748-A8DC-F79D-B21FDDEDDFAE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BF0354AE-3748-A8DC-F79D-B21FDDEDDFAE}.Debug|x64.ActiveCfg = Debug|Any CPU + {BF0354AE-3748-A8DC-F79D-B21FDDEDDFAE}.Debug|x64.Build.0 = Debug|Any CPU + {BF0354AE-3748-A8DC-F79D-B21FDDEDDFAE}.Debug|x86.ActiveCfg = Debug|Any CPU + {BF0354AE-3748-A8DC-F79D-B21FDDEDDFAE}.Debug|x86.Build.0 = Debug|Any CPU + {BF0354AE-3748-A8DC-F79D-B21FDDEDDFAE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BF0354AE-3748-A8DC-F79D-B21FDDEDDFAE}.Release|Any CPU.Build.0 = Release|Any CPU + {BF0354AE-3748-A8DC-F79D-B21FDDEDDFAE}.Release|x64.ActiveCfg = Release|Any CPU + {BF0354AE-3748-A8DC-F79D-B21FDDEDDFAE}.Release|x64.Build.0 = Release|Any CPU + {BF0354AE-3748-A8DC-F79D-B21FDDEDDFAE}.Release|x86.ActiveCfg = Release|Any CPU + {BF0354AE-3748-A8DC-F79D-B21FDDEDDFAE}.Release|x86.Build.0 = Release|Any CPU + {87C51120-6A0A-4D14-B644-1787DB6C6D6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87C51120-6A0A-4D14-B644-1787DB6C6D6E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {87C51120-6A0A-4D14-B644-1787DB6C6D6E}.Debug|x64.ActiveCfg = Debug|Any CPU + {87C51120-6A0A-4D14-B644-1787DB6C6D6E}.Debug|x64.Build.0 = Debug|Any CPU + {87C51120-6A0A-4D14-B644-1787DB6C6D6E}.Debug|x86.ActiveCfg = Debug|Any CPU + {87C51120-6A0A-4D14-B644-1787DB6C6D6E}.Debug|x86.Build.0 = Debug|Any CPU + {87C51120-6A0A-4D14-B644-1787DB6C6D6E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87C51120-6A0A-4D14-B644-1787DB6C6D6E}.Release|Any CPU.Build.0 = Release|Any CPU + {87C51120-6A0A-4D14-B644-1787DB6C6D6E}.Release|x64.ActiveCfg = Release|Any CPU + {87C51120-6A0A-4D14-B644-1787DB6C6D6E}.Release|x64.Build.0 = Release|Any CPU + {87C51120-6A0A-4D14-B644-1787DB6C6D6E}.Release|x86.ActiveCfg = Release|Any CPU + {87C51120-6A0A-4D14-B644-1787DB6C6D6E}.Release|x86.Build.0 = Release|Any CPU + {BE8CFF4C-E536-43DB-9D01-001E9A052D37}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {BE8CFF4C-E536-43DB-9D01-001E9A052D37}.Debug|Any CPU.Build.0 = Debug|Any CPU + {BE8CFF4C-E536-43DB-9D01-001E9A052D37}.Debug|x64.ActiveCfg = Debug|Any CPU + {BE8CFF4C-E536-43DB-9D01-001E9A052D37}.Debug|x64.Build.0 = Debug|Any CPU + {BE8CFF4C-E536-43DB-9D01-001E9A052D37}.Debug|x86.ActiveCfg = Debug|Any CPU + {BE8CFF4C-E536-43DB-9D01-001E9A052D37}.Debug|x86.Build.0 = Debug|Any CPU + {BE8CFF4C-E536-43DB-9D01-001E9A052D37}.Release|Any CPU.ActiveCfg = Release|Any CPU + {BE8CFF4C-E536-43DB-9D01-001E9A052D37}.Release|Any CPU.Build.0 = Release|Any CPU + {BE8CFF4C-E536-43DB-9D01-001E9A052D37}.Release|x64.ActiveCfg = Release|Any CPU + {BE8CFF4C-E536-43DB-9D01-001E9A052D37}.Release|x64.Build.0 = Release|Any CPU + {BE8CFF4C-E536-43DB-9D01-001E9A052D37}.Release|x86.ActiveCfg = Release|Any CPU + {BE8CFF4C-E536-43DB-9D01-001E9A052D37}.Release|x86.Build.0 = Release|Any CPU + {ADF14627-FCB5-4BD3-B65F-DDCC3A3F727C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ADF14627-FCB5-4BD3-B65F-DDCC3A3F727C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ADF14627-FCB5-4BD3-B65F-DDCC3A3F727C}.Debug|x64.ActiveCfg = Debug|Any CPU + {ADF14627-FCB5-4BD3-B65F-DDCC3A3F727C}.Debug|x64.Build.0 = Debug|Any CPU + {ADF14627-FCB5-4BD3-B65F-DDCC3A3F727C}.Debug|x86.ActiveCfg = Debug|Any CPU + {ADF14627-FCB5-4BD3-B65F-DDCC3A3F727C}.Debug|x86.Build.0 = Debug|Any CPU + {ADF14627-FCB5-4BD3-B65F-DDCC3A3F727C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ADF14627-FCB5-4BD3-B65F-DDCC3A3F727C}.Release|Any CPU.Build.0 = Release|Any CPU + {ADF14627-FCB5-4BD3-B65F-DDCC3A3F727C}.Release|x64.ActiveCfg = Release|Any CPU + {ADF14627-FCB5-4BD3-B65F-DDCC3A3F727C}.Release|x64.Build.0 = Release|Any CPU + {ADF14627-FCB5-4BD3-B65F-DDCC3A3F727C}.Release|x86.ActiveCfg = Release|Any CPU + {ADF14627-FCB5-4BD3-B65F-DDCC3A3F727C}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1852,6 +2161,9 @@ Global {F875D02C-2C2B-3465-24BB-4957ACDF70AD} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} {BB85F6EF-A9D2-63B8-1A53-B68D57B18799} = {F875D02C-2C2B-3465-24BB-4957ACDF70AD} {3D4E5F6A-7B8C-9D0E-1F2A-3B4C5D6E7F8A} = {BB85F6EF-A9D2-63B8-1A53-B68D57B18799} + {E19EB0A0-F682-2D4D-E69C-FF5DD6980CB7} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} + {EC9869D6-5301-5ADB-CC59-9E37821F5203} = {E19EB0A0-F682-2D4D-E69C-FF5DD6980CB7} + {E2C2A3E4-1D5F-4F6E-9A7F-1C2E54F893AB} = {EC9869D6-5301-5ADB-CC59-9E37821F5203} {03B49640-8ED6-23C8-1C29-10AEAF3F6973} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} {A0C524B1-9E68-5D79-F404-7E079F138486} = {03B49640-8ED6-23C8-1C29-10AEAF3F6973} {7828AB14-5E8B-4E60-B8AA-988E70573684} = {A0C524B1-9E68-5D79-F404-7E079F138486} @@ -1876,6 +2188,12 @@ Global {9E20BC7A-4871-E449-D7C1-4770293578A3} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} {7C4CD8CB-4CAB-A05D-EBA1-82911CA4C7BB} = {9E20BC7A-4871-E449-D7C1-4770293578A3} {E77DA2D5-7AFD-43D6-B77A-C22A1167B856} = {7C4CD8CB-4CAB-A05D-EBA1-82911CA4C7BB} + {C512965D-7BB0-9820-B548-85E6E27E777F} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} + {63ADDEB0-FC34-0EAE-BA79-5E41D9CAA086} = {C512965D-7BB0-9820-B548-85E6E27E777F} + {D7B16F40-8636-4EE4-9F9A-77DF5A92EE03} = {63ADDEB0-FC34-0EAE-BA79-5E41D9CAA086} + {59CA5914-CD73-F72D-5AE2-2588F9749673} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} + {6B5A97A9-D4ED-154B-D06B-95186CD1FF1C} = {59CA5914-CD73-F72D-5AE2-2588F9749673} + {359D3A43-1DFB-4541-AC72-E63EF0D6B3C9} = {6B5A97A9-D4ED-154B-D06B-95186CD1FF1C} {C3450695-99A5-5CF8-F6BD-D2019CEF67AE} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} {5DDB903B-9950-F24C-C972-E88A4AADF5AC} = {C3450695-99A5-5CF8-F6BD-D2019CEF67AE} {C8612FFE-7E8F-4EC4-B158-A4C2167A9AD5} = {5DDB903B-9950-F24C-C972-E88A4AADF5AC} @@ -1885,6 +2203,9 @@ Global {C0D09155-BD9F-CB19-962D-DAF1E886BDCF} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} {33C33874-E52E-07CC-0DDA-2389CB6FF63D} = {C0D09155-BD9F-CB19-962D-DAF1E886BDCF} {E1234567-1234-1234-1234-123456789012} = {33C33874-E52E-07CC-0DDA-2389CB6FF63D} + {8CB42B58-89CF-D92F-E189-16F031184C0B} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} + {40DBA547-7429-3933-14A0-AA798EF058C1} = {8CB42B58-89CF-D92F-E189-16F031184C0B} + {632DF404-7E5D-56B7-808D-816A3AB773A4} = {40DBA547-7429-3933-14A0-AA798EF058C1} {0D319E14-6DDE-C12F-A5B0-76EED797D651} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} {917EE497-2381-F55E-88F6-47586FC57241} = {0D319E14-6DDE-C12F-A5B0-76EED797D651} {2DF5CFE5-EF8D-4BE4-96D0-C256F2B52011} = {917EE497-2381-F55E-88F6-47586FC57241} @@ -1933,6 +2254,12 @@ Global {1ED11E6E-94A0-E908-ACE4-8E9BD19CC7E1} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} {075C408B-AC75-B3AA-88FB-1AE42826BD50} = {1ED11E6E-94A0-E908-ACE4-8E9BD19CC7E1} {F575C6A0-59AF-455D-8B7A-5421D4BAAA2F} = {075C408B-AC75-B3AA-88FB-1AE42826BD50} + {84B02E00-2E23-1547-B733-D86A059C7EE0} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} + {717ED564-5195-F490-103F-F6C7788C6E34} = {84B02E00-2E23-1547-B733-D86A059C7EE0} + {9B667E4D-C905-4CA4-B640-0F3B89293CA8} = {717ED564-5195-F490-103F-F6C7788C6E34} + {AB4067EC-C276-E50A-4A3C-F9DDFC3B9D29} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} + {7AC89CF8-E7AC-9EE9-D7AA-392DECC2CF04} = {AB4067EC-C276-E50A-4A3C-F9DDFC3B9D29} + {C3D4E5F6-7890-ABCD-EF12-345678901234} = {7AC89CF8-E7AC-9EE9-D7AA-392DECC2CF04} {C64ABA23-B3DB-4B75-3019-7CBBB134BB29} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} {04A069BA-F670-FF57-4260-3772155E86F2} = {C64ABA23-B3DB-4B75-3019-7CBBB134BB29} {C84A54B1-BFF9-4394-A6BF-135C2FC37A64} = {04A069BA-F670-FF57-4260-3772155E86F2} @@ -1974,6 +2301,8 @@ Global {57C9E70F-2035-4217-8D35-C5498103E52C} = {06C3F726-79B1-E9B3-C592-70534457DCBA} {6F99F316-9387-9CAC-AEB4-15F1E9D669C8} = {F875D02C-2C2B-3465-24BB-4957ACDF70AD} {B2C3D4E5-F6A7-8901-BCDE-F23456789012} = {6F99F316-9387-9CAC-AEB4-15F1E9D669C8} + {FA658F27-CA29-C4FC-A436-483AE994B789} = {E19EB0A0-F682-2D4D-E69C-FF5DD6980CB7} + {C0F9D8F5-3F6D-4D46-9E6D-8A7E4EDC0E11} = {FA658F27-CA29-C4FC-A436-483AE994B789} {047042FC-DF09-5707-F2AE-666AE9547EAC} = {03B49640-8ED6-23C8-1C29-10AEAF3F6973} {10E02570-BC66-4389-AD30-2E19CB9BD700} = {047042FC-DF09-5707-F2AE-666AE9547EAC} {9D157338-0F03-4A4C-AD84-39E04B6368BF} = {047042FC-DF09-5707-F2AE-666AE9547EAC} @@ -1994,6 +2323,12 @@ Global {88780872-11A6-4014-B575-75C960EC0968} = {18221FA7-BAC2-07BA-D54F-D567400B25DA} {D03EF84F-1D93-1805-F24E-6781FF2EE6D9} = {9E20BC7A-4871-E449-D7C1-4770293578A3} {2135A527-3910-44B5-A778-C5467D0A6148} = {D03EF84F-1D93-1805-F24E-6781FF2EE6D9} + {FCBD2AD5-0A0E-7EFF-287B-AA167C8934A9} = {C512965D-7BB0-9820-B548-85E6E27E777F} + {02276871-6582-4A21-B629-83527FD90559} = {FCBD2AD5-0A0E-7EFF-287B-AA167C8934A9} + {DDF0DC50-766B-4E49-AA46-8D7F2370465A} = {FCBD2AD5-0A0E-7EFF-287B-AA167C8934A9} + {6449F0B7-FD03-89F1-ED21-7DE8FE1BCD0A} = {59CA5914-CD73-F72D-5AE2-2588F9749673} + {8F8153F8-6BED-4026-89A5-FEE2FEF7D379} = {6449F0B7-FD03-89F1-ED21-7DE8FE1BCD0A} + {FD1A0CD2-19BF-443E-9687-83E5B9E438EE} = {6449F0B7-FD03-89F1-ED21-7DE8FE1BCD0A} {399EB16B-AE75-8AFC-99AF-1927C5624042} = {C3450695-99A5-5CF8-F6BD-D2019CEF67AE} {F3AD707A-5E2D-4F58-BA21-691B4C7D1BA5} = {399EB16B-AE75-8AFC-99AF-1927C5624042} {B73E94EA-256E-4C1C-956B-0D4E67B5C82F} = {399EB16B-AE75-8AFC-99AF-1927C5624042} @@ -2003,6 +2338,9 @@ Global {FD32D82B-24D4-2B38-C449-6062D56010E9} = {C0D09155-BD9F-CB19-962D-DAF1E886BDCF} {D4ADBF73-AE37-4614-9C07-8C71DC85F8EA} = {FD32D82B-24D4-2B38-C449-6062D56010E9} {E1234569-1234-1234-1234-1234567890AE} = {FD32D82B-24D4-2B38-C449-6062D56010E9} + {DF6A3634-B09B-FAAE-C9A7-03F46588271A} = {8CB42B58-89CF-D92F-E189-16F031184C0B} + {760CAD28-4792-44B8-77D1-0F42F47BD303} = {DF6A3634-B09B-FAAE-C9A7-03F46588271A} + {459CFD26-F5BC-B3C8-0632-DE7EA1504AD3} = {DF6A3634-B09B-FAAE-C9A7-03F46588271A} {81E3F68B-68AA-1E51-D1D2-DE3FA9DB90AD} = {0D319E14-6DDE-C12F-A5B0-76EED797D651} {CE923FCF-F208-4BC5-8C9B-AA54E2930993} = {81E3F68B-68AA-1E51-D1D2-DE3FA9DB90AD} {2BABADA8-D4EC-AEA2-C105-00C894829DE1} = {4179CBCC-D958-52F0-A182-FBFEBF000264} @@ -2047,6 +2385,12 @@ Global {0F04000E-CD22-741D-E99C-16D60B397F54} = {1ED11E6E-94A0-E908-ACE4-8E9BD19CC7E1} {9BE18521-31F9-45AD-A6EE-74695754EC0C} = {0F04000E-CD22-741D-E99C-16D60B397F54} {F90633BA-133F-42E3-88D5-EE4DE0FB0586} = {0F04000E-CD22-741D-E99C-16D60B397F54} + {7331D996-A74A-CB9F-1FD5-3D31CD825A37} = {84B02E00-2E23-1547-B733-D86A059C7EE0} + {DE85A82A-CC7A-4743-B6FE-7259B152EF26} = {7331D996-A74A-CB9F-1FD5-3D31CD825A37} + {8066FB34-4946-4A49-B3EC-E0E1D28760D1} = {7331D996-A74A-CB9F-1FD5-3D31CD825A37} + {1474C41C-43C3-EE73-1D18-99B5FE232042} = {AB4067EC-C276-E50A-4A3C-F9DDFC3B9D29} + {E5F6A7B8-90CD-EF12-3456-7890ABCDEF01} = {1474C41C-43C3-EE73-1D18-99B5FE232042} + {F6A7B890-CDEF-1234-5678-90ABCDEF0123} = {1474C41C-43C3-EE73-1D18-99B5FE232042} {AE64BB99-52FC-85C8-81F2-9C84ECFCBB2D} = {C64ABA23-B3DB-4B75-3019-7CBBB134BB29} {8EAD1CA0-DD5D-493A-A6A4-45DD8E6835EA} = {AE64BB99-52FC-85C8-81F2-9C84ECFCBB2D} {9457321A-9019-41E8-9E80-309D76EF2BAF} = {AE64BB99-52FC-85C8-81F2-9C84ECFCBB2D} @@ -2061,6 +2405,16 @@ Global {C031D592-96D6-44D0-BF31-33CCE5CAABA1} = {447CAF13-A1DE-A946-989B-DD6CAA0CDF92} {294AC723-70DA-F50A-2C7A-AC6C0AEA0A62} = {9072C7AF-9EB2-E481-3974-77957587AC76} {D3F46C2D-3AFD-FD9C-9C6A-180B1514DD2F} = {294AC723-70DA-F50A-2C7A-AC6C0AEA0A62} + {BF0354AE-3748-A8DC-F79D-B21FDDEDDFAE} = {37B0CE47-14C8-F5BF-BDDD-13EEBE580A88} + {156D9C17-61FD-98D6-32C0-065B406D0434} = {07C2787E-EAC7-C090-1BA3-A61EC2A24D84} + {5D760DD8-DBA3-B865-9021-FDE8FD3497A8} = {156D9C17-61FD-98D6-32C0-065B406D0434} + {87C51120-6A0A-4D14-B644-1787DB6C6D6E} = {5D760DD8-DBA3-B865-9021-FDE8FD3497A8} + {50124EEC-97B0-320E-80D4-8464D7692B22} = {156D9C17-61FD-98D6-32C0-065B406D0434} + {BE8CFF4C-E536-43DB-9D01-001E9A052D37} = {50124EEC-97B0-320E-80D4-8464D7692B22} + {319B94CD-694C-16E8-9E3A-9577B99158DD} = {F7E192D1-DE6C-42A2-B52F-02849D482450} + {ADF14627-FCB5-4BD3-B65F-DDCC3A3F727C} = {319B94CD-694C-16E8-9E3A-9577B99158DD} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {926577F9-9246-44E4-BCE9-25DB003F1C51} EndGlobalSection EndGlobal - diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 94ef6f8724..7514d0fa78 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,6 +36,7 @@ If you are contributing significant changes, or if the issue is already assigned - [Running the Analysis](#running-the-analysis) - [Installing Git Hooks](#installing-git-hooks) - [Model Context Protocol (MCP)](#model-context-protocol-mcp) + - [Package README](#package-readme) - [Advanced Configuration](#advanced-configuration) - [Configuring External MCP Servers](#configuring-external-mcp-servers) - [Registry Configuration](#registry-configuration) @@ -55,7 +56,7 @@ If you are contributing significant changes, or if the issue is already assigned ## Getting Started -> [!IMPORTANT] +> [!IMPORTANT] > If you are a **Microsoft employee** then please also review our [Azure Internal Onboarding Documentation](https://aka.ms/azmcp/intake) for getting setup ### Prerequisites @@ -103,7 +104,7 @@ If you are contributing significant changes, or if the issue is already assigned ### Adding a New Command -> [!TIP] +> [!TIP] > **Submit One Tool Per Pull Request** > > We strongly recommend submitting **one tool per pull request** to streamline the review process and provide better onboarding experience. This approach results in: @@ -131,16 +132,32 @@ If you are contributing significant changes, or if the issue is already assigned "create [namespace] [resource] [operation] command using #new-command.md as a reference" ``` -4. **Follow implementation guidelines** in [docs/new-command.md](https://github.com/microsoft/mcp/blob/main/docs/new-command.md) +4. **Follow implementation guidelines** in [docs/new-command.md](https://github.com/microsoft/mcp/blob/main/servers/Azure.Mcp.Server/docs/new-command.md) 5. **Update documentation**: - - Add the new command to [/docs/azmcp-commands.md](https://github.com/microsoft/mcp/blob/main/docs/azmcp-commands.md) - - Add test prompts for the new command in [/docs/e2eTestPrompts.md](https://github.com/microsoft/mcp/blob/main/docs/e2eTestPrompts.md) + - Add the new command to [/servers/Azure.Mcp.Server/docs/azmcp-commands.md](https://github.com/microsoft/mcp/blob/main/servers/Azure.Mcp.Server/docs/azmcp-commands.md) + - Run `.\eng\scripts\Update-AzCommandsMetadata.ps1` to update tool metadata in azmcp-commands.md (required for CI) + - Add test prompts for the new command in [/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md](https://github.com/microsoft/mcp/blob/main/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md) - Update [README.md](https://github.com/microsoft/mcp/blob/main/README.md) to mention the new command 6. **Add CODEOWNERS entry** in [CODEOWNERS](https://github.com/microsoft/mcp/blob/main/.github/CODEOWNERS) [(example)](https://github.com/microsoft/mcp/commit/08f73efe826d5d47c0f93be5ed9e614740e82091) -7. **Create Pull Request**: +7. **Add new tool to consolidated mode**: + - Open `core/Azure.Mcp.Core/src/Areas/Server/Resources/consolidated-tools.json` file, where the tool grouping definition is stored for consolidated mode. In Agent mode, add it to the chat as context. + - Paste the follow prompt for Copilot to generate the change to add the new tool: + ```txt + I have this list of tools which haven't been matched with any consolidated tools in this file. Help me add them to the one with the best matching category and exact matching toolMetadata. Update existing consolidated tools where newly mapped tools are added. If you can't find one, suggest a new consolidated tool. + + + ``` + - Use the following command to find out the correct tool name for your new tool + ``` + cd servers/Azure.Mcp.Server/src/bin/Debug/net9.0 + ./azmcp[.exe] tools list --name --namespace + ``` + - Commit the change. + +8. **Create Pull Request**: - Reference the issue you created - Include tests in the `/tests` folder - Ensure all tests pass @@ -172,9 +189,17 @@ Requirements: - Mock external service calls - Test argument validation +#### Cancellation plumbing + +To ensure the product code and unit tests can be cancelled quickly, contributors are required to write async methods (any returning `Task`, `ValueTask`, generic variants of those, etc.) to accept and invoke async methods with a `System.Threading.CancellationToken` parameter. The latter is enforced with the [CA2016 analyzer](https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca2016). + +Mocks created with `NSubstitute.Substitue.For()` and have [methods set up](https://nsubstitute.github.io/help/set-return-value/#for-methods) should be passed `NSubstitute.Arg.Any()` for required `System.Threading.CancellationToken` parameters. The same should be used when [checking for received calls on a mocked object](https://nsubstitute.github.io/help/received-calls/index.html). If the product code is expected to do something interesting with a supplied `System.Threading.CancellationToken` parameter, such as linking with other `System.Threading.CancellationToken`s with [`System.Threading.CancellationTokenSource.CreateLinkedTokenSource`](https://learn.microsoft.com/dotnet/api/system.threading.cancellationtokensource.createlinkedtokensource), then consider testing for that behavior. + +Real product code under unit testing must be passed `Xunit.TestContext.Current.CancellationToken` when async methods are invoked. This is to ensure the tests can end to avoid possible issues with the parent process waiting indefinitely for the test runner executable to exit. + ### End-to-end Tests -End-to-end tests are performed manually. Command authors must thoroughly test each command to ensure correct tool invocation and results. At least one prompt per tool is required and should be added to `/docs/e2eTestPrompts.md`. +End-to-end tests are performed manually. Command authors must thoroughly test each command to ensure correct tool invocation and results. At least one prompt per tool is required and should be added to `/servers/Azure.Mcp.Server/docs/e2eTestPrompts.md`. ### Testing Local Build with VS Code @@ -188,9 +213,96 @@ Build the project at the root directory of this repository: dotnet build ``` +#### Run the Azure MCP server in HTTP mode + +**Option 1: Using dotnet run (uses launchSettings.json)** + +**Prerequisites: Create launchSettings.json** + +> [!NOTE] +> Internal contributors may skip this step as the `launchSettings.json` file is already provided in the repository. + +Before running the server in HTTP mode, you need to create the `launchSettings.json` file with the `debug-remotemcp` profile: + +1. Create the directory (if it doesn't exist): + ```bash + mkdir -p servers/Azure.Mcp.Server/src/Properties + ``` + +2. Create `servers/Azure.Mcp.Server/src/Properties/launchSettings.json` with the following content: + ```json + { + "profiles": { + "debug-remotemcp": { + "commandName": "Project", + "commandLineArgs": "server start --transport http --outgoing-auth-strategy UseHostingEnvironmentIdentity", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "ASPNETCORE_URLS": "http://localhost:", + "AzureAd__TenantId": "", + "AzureAd__ClientId": "", + "AzureAd__Instance": "https://login.microsoftonline.com/" + } + } + } + } + ``` + +3. Replace `` and `` with your actual tenant ID and client ID. + +```bash +dotnet run --project servers/Azure.Mcp.Server/src/ --launch-profile debug-remotemcp +``` + +**Option 2: Using the built executable directly** + +Build the project first, then run the executable with the necessary environment variables: + +```powershell +# Set environment variables (PowerShell) +$env:ASPNETCORE_ENVIRONMENT = "Development" +$env:ASPNETCORE_URLS = "http://localhost:" +$env:AzureAd__TenantId = "" +$env:AzureAd__ClientId = "" +$env:AzureAd__Instance = "https://login.microsoftonline.com/" + +# Run the executable +./servers/Azure.Mcp.Server/src/bin/Debug/net9.0/azmcp.exe server start --transport http --outgoing-auth-strategy UseHostingEnvironmentIdentity +``` + +```bash +# Set environment variables (Bash) +export ASPNETCORE_ENVIRONMENT="Development" +export ASPNETCORE_URLS="http://localhost:" +export AzureAd__TenantId="" +export AzureAd__ClientId="" +export AzureAd__Instance="https://login.microsoftonline.com/" + +# Run the executable +./servers/Azure.Mcp.Server/src/bin/Debug/net9.0/azmcp server start --transport http --outgoing-auth-strategy UseHostingEnvironmentIdentity +``` + +> **Note:** The environment variables listed above are taken from the `debug-remotemcp` profile in `launchSettings.json`. Replace `` and `` with your actual Azure AD tenant ID and client ID. These variables configure Azure AD authentication and the server endpoint for HTTP mode operation. +> +> For local development, when running with HTTPS (either via `ASPNETCORE_URLS` or HTTPS redirection), you must generate a self-signed development certificate: +> +>**Windows and macOS:** +> ```bash +> dotnet dev-certs https --trust +> ``` +> +> **Linux:** +> ```bash +> dotnet dev-certs https +> ``` +> +> On Linux, you must manually trust the generated certificate. See the [official documentation](https://learn.microsoft.com/dotnet/core/tools/dotnet-dev-certs) for instructions on how to do this. + #### Configure mcp.json -Update your mcp.json to point to the locally built azmcp executable: +Update your mcp.json to point to the locally built azmcp executable. + +**Stdio Mode:** ```json { @@ -204,9 +316,23 @@ Update your mcp.json to point to the locally built azmcp executable: } ``` +**HTTP Mode:** + +```json +{ + "servers": { + "Azure MCP Server": { + "url": "https://localhost:1031/", + "type": "http" + } + } +} +``` + > [!NOTE] -> Replace `` with the full path to your built executable. +> For stdio mode, replace `` with the full path to your built executable. > On **Windows**, use `azmcp.exe`. On **macOS/Linux**, use `azmcp`. +> For HTTP mode, ensure the server is running on the specified port before connecting (port 1031 is the default port configured in launchSettings.json). #### Server Modes @@ -268,7 +394,22 @@ Optional `--namespace` and `--mode` parameters can be used to configure differen } ``` -**Combined Mode** (filter namespaces with proxy mode): +**Consolidated Mode** (grouped related operations): +It honors both --read-only and --namespace switches. + +```json +{ + "servers": { + "azure-mcp-server": { + "type": "stdio", + "command": "/mcp/servers/Azure.Mcp.Server/src/bin/Debug/net9.0/azmcp[.exe]", + "args": ["server", "start", "--mode", "consolidated"] + } + } +} +``` + +**Combined Mode** (filter namespaces with any mode): ```json { @@ -282,13 +423,30 @@ Optional `--namespace` and `--mode` parameters can be used to configure differen } ``` +**Specific Tool Mode** (expose only specific tools): + +```json +{ + "servers": { + "azure-mcp-server": { + "type": "stdio", + "command": "/mcp/servers/Azure.Mcp.Server/src/bin/Debug/net9.0/azmcp[.exe]", + "args": ["server", "start", "--tool", "azmcp_storage_account_get", "--tool", "azmcp_subscription_list"] + } + } +} +``` + > **Server Mode Summary:** > -> - **Default Mode**: No additional parameters - exposes all tools individually +> - **Default Mode (Namespace)**: No additional parameters - collapses tools by namespace (current default) +> - **Consolidated Mode**: `--mode consolidated` - exposes consolidated tools grouping related operations, optimized for AI agents. > - **Namespace Mode**: `--namespace ` - expose specific services > - **Namespace Proxy Mode**: `--mode namespace` - collapse tools by namespace (useful for VS Code's 128 tool limit) +> - **All Tools Mode**: `--mode all` - expose all ~800+ individual tools > - **Single Tool Mode**: `--mode single` - single "azure" tool with internal routing -> - **Combined Mode**: Both `--namespace` and `--mode` can be used together +> - **Specific Tool Mode**: `--tool ` - expose only specific tools by name (finest granularity) +> - **Combined Mode**: Multiple options can be used together (`--namespace` + `--mode` etc.) #### Start from IDE @@ -298,7 +456,7 @@ With the configuration in place, you can launch the MCP server directly from you To build a local image for testing purposes: -1. Execute: `./eng/scripts/Build-Docker.ps1`. +1. Execute: `./eng/scripts/Build-Docker.ps1 -ServerName "Azure.Mcp.Server"`. 2. Update `mcp.json` to point to locally built Docker image: ```json @@ -312,7 +470,7 @@ To build a local image for testing purposes: "--rm", "--env-file", "/full/path/to/.env" - "azure/azure-mcp:", + "azure-sdk/azure-mcp:", ] } } @@ -405,7 +563,7 @@ This section assumes that the necessary Azure resources for live tests are alrea To debug the Azure MCP Server (`azmcp`) when running live tests in VS Code: -1. Build the package with debug symbols: `./eng/scripts/Build-Local.ps1 -DebugBuild` +1. Build the package with debug symbols: `./eng/scripts/Build-Local.ps1` 2. Set a breakpoint in a command file (e.g., [`KeyValueListCommand.ExecuteAsync`](https://github.com/microsoft/mcp/blob/4ed650a0507921273acc7b382a79049809ef39c1/src/Commands/AppConfig/KeyValue/KeyValueListCommand.cs#L48)) 3. In VS Code, navigate to a test method (e.g., [`AppConfigCommandTests::Should_list_appconfig_kvs()`](https://github.com/microsoft/mcp/blob/4ed650a0507921273acc7b382a79049809ef39c1/tests/Client/AppConfigCommandTests.cs#L56)), add a breakpoint to `CallToolAsync` call in the test method, then right-click and select **Debug Test** 4. Find the `azmcp` process ID: @@ -487,6 +645,42 @@ The Azure MCP Server implements the [Model Context Protocol specification](https - Handle errors according to MCP specifications - Provide proper argument suggestions +### Package README + +A single package README.md could be used to generate context specific content for different package types (npm, nuget, vsix) using html comment annotations to mark sections for removal or insertion whem processed with script at `.\eng\scripts\Process-PackageReadMe.ps1` + +Supported comment annotations: + +- Section Removal + - **Purpose:** Remove one or more lines, or parts of a line of markdown for specified package types. + - **Example:** + ``` + + ...... + various markdown lines to be removed for nuget and npm + ...... + + ``` + +- Section Insert + - **Purpose:** Insert a chunk of text into a line for a specified package type. + - **Example:** + `` + +You can verify that your README.md was annotated correctly using the `Validate-PackageReadme` function: +``` +& "eng\scripts\Process-PackageReadMe.ps1" -Command "validate" -InputReadMePath "" +``` + +To extract README.md for a specific package, run the `Extract-PackageSpecificReadMe` function: +``` +& "eng\scripts\Process-PackageReadMe.ps1" -Command "extract" ` + -InputReadMePath "" ` + -OutputDirectory "" ` + -PackageType "" ` + -InsertPayload "" +``` + ## Advanced Configuration ### Configuring External MCP Servers @@ -598,6 +792,14 @@ If you would like to see the product of a PR as a package on the dev feed, after Instructions for consuming the package from the dev feed can be found in the "Extensions" tab of the pipeline run page. +**CI Validation Checks**: + +All PRs automatically run the following validation checks: +- **Code formatting** - Ensures code follows project formatting standards +- **Spelling check** - Validates spelling across the codebase +- **AOT compatibility** - Checks ahead-of-time compilation compatibility +- **Tool metadata verification** - Ensures `azmcp-commands.md` is up-to-date with tool metadata (run `.\eng\scripts\Update-AzCommandsMetadata.ps1` if this fails) + ## Support and Community Please see our [support](https://github.com/microsoft/mcp/blob/main/SUPPORT.md) statement. @@ -611,7 +813,7 @@ We're building this in the open. Your feedback is much appreciated, and will he ### Additional Resources - [Azure MCP Documentation](https://github.com/microsoft/mcp/blob/main/README.md) -- [Command Implementation Guide](https://github.com/microsoft/mcp/blob/main/docs/new-command.md) +- [Command Implementation Guide](https://github.com/microsoft/mcp/blob/main/servers/Azure.Mcp.Server/docs/new-command.md) - [VS Code Insiders Download](https://code.visualstudio.com/insiders/) - [GitHub Copilot Documentation](https://docs.github.com/en/copilot) diff --git a/Directory.Build.props b/Directory.Build.props index cbd34fb359..c9bceecf58 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,18 +14,29 @@ <_RelativeProjectDir>$([System.IO.Path]::GetFullPath('$(MSBuildProjectDirectory)').Replace($([System.IO.Path]::GetFullPath('$(RepoRoot)')), '')) true + $(DefineConstants);BUILD_NATIVE + + + + + $(DefineConstants);ENABLE_HTTP + + Guard + true true McpServer + $(RepoRoot)eng/images/microsofticon.png MIT true @@ -34,9 +45,11 @@ true true + - $(RepoRoot)/eng/dnx/.mcp/server.json + $(RepoRoot)eng/dnx/.mcp/server.json + true @@ -44,4 +57,10 @@ Size true + + + Microsoft Corporation + © Microsoft Corporation. All rights reserved. + https://github.com/microsoft/mcp + diff --git a/Directory.Packages.props b/Directory.Packages.props index fc4916ccbd..d6762f6c67 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -4,11 +4,13 @@ + + - + @@ -17,24 +19,24 @@ + - - - + + - + @@ -45,47 +47,51 @@ + + - - + - - - - - - - - - - + + + + + + + + + + + + + - + - - + + - - - - + + + + - + - - + + @@ -93,7 +99,6 @@ - - + - \ No newline at end of file + diff --git a/Dockerfile b/Dockerfile index d2932363c1..0218f4746d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,5 @@ # Build the runtime image -FROM mcr.microsoft.com/dotnet/aspnet:9.0.8-bookworm-slim AS runtime +FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine AS runtime # Add build argument for publish directory ARG PUBLISH_DIR @@ -9,16 +9,31 @@ RUN if [ -z "$PUBLISH_DIR" ]; then \ echo "ERROR: PUBLISH_DIR build argument is required" && exit 1; \ fi -# Copy the contents of the publish directory to '/azuremcpserver' and set it as the working directory -RUN mkdir -p /azuremcpserver -COPY ${PUBLISH_DIR} /azuremcpserver/ -WORKDIR /azuremcpserver +# Add build argument for executable name +ARG EXECUTABLE_NAME + +# Error out if EXECUTABLE_NAME is not set +RUN if [ -z "$EXECUTABLE_NAME" ]; then \ + echo "ERROR: EXECUTABLE_NAME build argument is required" && exit 1; \ + fi + +RUN apk add --no-cache libc6-compat + +# Copy the contents of the publish directory to '/mcp-server' and set it as the working directory +RUN mkdir -p /mcp-server +COPY ${PUBLISH_DIR} /mcp-server/ +WORKDIR /mcp-server # List the contents of the current directory RUN ls -la -RUN if [ ! -f "azmcp" ]; then \ - echo "ERROR: azmcp executable does not exist" && exit 1; \ +# Ensure the server binary exists +RUN if [ ! -f $EXECUTABLE_NAME ]; then \ + echo "ERROR: $EXECUTABLE_NAME executable does not exist" && exit 1; \ fi + +# Copy the server binary to a known location and make it executable +COPY ${PUBLISH_DIR}/${EXECUTABLE_NAME} server-binary +RUN chmod +x server-binary && test -x server-binary -ENTRYPOINT ["./azmcp", "server", "start"] \ No newline at end of file +ENTRYPOINT ["./server-binary", "server", "start"] diff --git a/NOTICE.txt b/NOTICE.txt index 067e5b9845..af307d2125 100644 --- a/NOTICE.txt +++ b/NOTICE.txt @@ -7088,7 +7088,7 @@ SOFTWARE. --------------------------------------------------------- -coverlet.collector 6.0.2 - MIT +coverlet.collector 6.0.4 - MIT Copyright 2008 - 2018 Jb Evain diff --git a/README.md b/README.md index 9a16811ae4..91267916b9 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ This repository contains core libraries, test frameworks, engineering systems, p [Azure MCP README]: https://github.com/microsoft/mcp/blob/main/servers/Azure.Mcp.Server/README.md [Azure MCP CHANGELOG]: https://github.com/microsoft/mcp/blob/main/servers/Azure.Mcp.Server/CHANGELOG.md [Azure MCP Source Code]: https://github.com/microsoft/mcp/blob/main/servers/Azure.Mcp.Server -[Azure MCP Releases]: https://github.com/microsoft/mcp/releases?q=Azure.Mcp.Server-0 +[Azure MCP Releases]: https://github.com/microsoft/mcp/releases?q=Azure.Mcp.Server- [Azure MCP Documentation]: https://learn.microsoft.com/azure/developer/azure-mcp-server/ [Azure MCP Troubleshooting]: https://github.com/microsoft/mcp/blob/main/servers/Azure.Mcp.Server/TROUBLESHOOTING.md [Azure MCP Support]: https://github.com/microsoft/mcp/blob/main/servers/Azure.Mcp.Server/SUPPORT.md @@ -31,7 +31,7 @@ This repository contains core libraries, test frameworks, engineering systems, p [Fabric MCP README]: https://github.com/microsoft/mcp/blob/main/servers/Fabric.Mcp.Server/README.md [Fabric MCP CHANGELOG]: https://github.com/microsoft/mcp/blob/main/servers/Fabric.Mcp.Server/CHANGELOG.md [Fabric MCP Source Code]: https://github.com/microsoft/mcp/blob/main/servers/Fabric.Mcp.Server -[Fabric MCP Releases]: https://github.com/microsoft/mcp/releases?q=Fabric.Mcp.Server-0 +[Fabric MCP Releases]: https://github.com/microsoft/mcp/releases?q=Fabric.Mcp.Server- [Fabric Documentation]: https://learn.microsoft.com/fabric/ [Fabric MCP Troubleshooting]: https://github.com/microsoft/mcp/blob/main/servers/Fabric.Mcp.Server/TROUBLESHOOTING.md [Fabric MCP Support]: https://github.com/microsoft/mcp/blob/main/servers/Fabric.Mcp.Server/SUPPORT.md @@ -44,20 +44,21 @@ This repository contains core libraries, test frameworks, engineering systems, p - **DESCRIPTION**: All Azure MCP tools in a single server. The Azure MCP Server implements the MCP specification to create a seamless connection between AI agents and Azure services. Azure MCP Server can be used alone or with the GitHub Copilot for Azure extension in VS Code. - **CATEGORY**: `CLOUD AND INFRASTRUCTURE` - **TYPE**: `Local` -- **INSTALL**: [![Install Azure MCP in VS Code](https://img.shields.io/badge/VS_Code-Install_Azure_MCP_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect?url=vscode:extension/ms-azuretools.vscode-azure-mcp-server) [![Install Azure MCP in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Azure_MCP_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect?url=vscode-insiders:extension/ms-azuretools.vscode-azure-mcp-server) [![Install Azure MCP in Visual Studio](https://img.shields.io/badge/Visual_Studio-Install_Azure_MCP_Server-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://marketplace.visualstudio.com/items?itemName=github-copilot-azure.GitHubCopilotForAzure2022) [![Install Azure MCP Server](https://img.shields.io/badge/IntelliJ%20IDEA-Install%20Azure%20MCP%20Server-1495b1?style=flat-square&logo=intellijidea&logoColor=white)](https://plugins.jetbrains.com/plugin/8053) -### ✨ Azure AI Foundry -- **REPOSITORY**: [azure-ai-foundry/mcp-foundry](https://github.com/azure-ai-foundry/mcp-foundry) -- **DESCRIPTION**: A Model Context Protocol server for Azure AI Foundry, providing a unified set of tools for models, knowledge, evaluation, and more. +- **INSTALL**: [![Install Azure MCP in VS Code](https://img.shields.io/badge/VS_Code-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect?url=vscode:extension/ms-azuretools.vscode-azure-mcp-server) [![Install Azure MCP in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect?url=vscode-insiders:extension/ms-azuretools.vscode-azure-mcp-server) [![Install Azure MCP in Visual Studio](https://img.shields.io/badge/Visual_Studio-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://marketplace.visualstudio.com/items?itemName=github-copilot-azure.GitHubCopilotForAzure2022) [![Install Azure MCP in IntelliJ](https://img.shields.io/badge/IntelliJ%20IDEA-1495b1?style=flat-square&logo=intellijidea&logoColor=white)](https://plugins.jetbrains.com/plugin/8053) [![Install Azure MCP in Eclipse](https://img.shields.io/badge/Eclipse-b6ae1d?style=flat-square&logo=eclipse&logoColor=white)](https://marketplace.eclipse.org/content/azure-toolkit-eclipse) + +### ✨ Microsoft Foundry +- **DOCUMENTATION**: [Get started with Foundry MCP Server](https://learn.microsoft.com/azure/ai-foundry/mcp/get-started?view=foundry&tabs=user) +- **DESCRIPTION**: A Model Context Protocol server for Microsoft Foundry, providing a unified set of tools for models, knowledge, evaluation, and more. - **CATEGORY**: `CLOUD AND INFRASTRUCTURE` -- **TYPE**: `Local` -- **INSTALL**: [![Install Azure AI Foundry MCP in VS Code](https://img.shields.io/badge/VS_Code-Install_AI_Foundry_MCP_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=ffffff)](https://vscode.dev/redirect?url=vscode:mcp/install?%7B%22name%22%3A%22ai_foundry_server%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22--prerelease%3Dallow%22%2C%22--from%22%2C%22git%2Bhttps%3A%2F%2Fgithub.com%2Fazure-ai-foundry%2Fmcp-foundry.git%22%2C%22run-azure-ai-foundry-mcp%22%2C%22--envFile%22%2C%22%24%7BworkspaceFolder%7D%2F.env%22%5D%7D) [![Install Azure AI Foundry in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_AI_Foundry_MCP_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=ffffff)](https://vscode.dev/redirect?url=vscode-insiders:mcp/install?%7B%22name%22%3A%22ai_foundry_server%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22--prerelease%3Dallow%22%2C%22--from%22%2C%22git%2Bhttps%3A%2F%2Fgithub.com%2Fazure-ai-foundry%2Fmcp-foundry.git%22%2C%22run-azure-ai-foundry-mcp%22%2C%22--envFile%22%2C%22%24%7BworkspaceFolder%7D%2F.env%22%5D%7D) [![Install Azure AI Foundry in Visual Studio](https://img.shields.io/badge/Visual_Studio-Install_Azure_AI_Foundry_Server-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22ai_foundry_server%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22--prerelease%3Dallow%22%2C%22--from%22%2C%22git%2Bhttps%3A%2F%2Fgithub.com%2Fazure-ai-foundry%2Fmcp-foundry.git%22%2C%22run-azure-ai-foundry-mcp%22%2C%22--envFile%22%2C%22%24%7BworkspaceFolder%7D%2F.env%22%5D%7D) +- **TYPE**: `REMOTE` - `https://mcp.ai.azure.com` +- **INSTALL**: [![Install Microsoft Foundry MCP in VS Code](https://img.shields.io/badge/VS_Code-0098FF?style=flat-square&logo=visualstudiocode&logoColor=ffffff)](https://vscode.dev/redirect?url=vscode:mcp/install?%7B%22name%22%3A%22foundry-mcp-remote%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fmcp.ai.azure.com%22%7D) [![Install Microsoft Foundry in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=ffffff)](https://vscode.dev/redirect?url=vscode-insiders:mcp/install?%7B%22name%22%3A%22foundry-mcp-remote%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fmcp.ai.azure.com%22%7D) ### Microsoft Azure DevOps Logo Azure DevOps -- **REPOSITORY**: [Azure DevOps MCP Server - Public Preview](https://github.com/microsoft/azure-devops-mcp) +- **REPOSITORY**: [Azure DevOps MCP Server](https://github.com/microsoft/azure-devops-mcp) - **DESCRIPTION**: This TypeScript project provides a local MCP server for Azure DevOps, enabling you to perform a wide range of Azure DevOps tasks directly from your code editor. - **CATEGORY**: `DEVELOPER TOOLS` - **TYPE**: `Local` -- **INSTALL**: [![Install Azure DevOps in VS Code](https://img.shields.io/badge/VS_Code-Install_Azure_DevOps_MCP_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=ado&type=stdio&command=npx&args=%5B%22-y%22%2C%22%40azure-devops%2Fmcp%22%2C%22%24%7Binput%3Aado_org%7D%22%5D&inputs=%5B%7B%22id%22%3A%22ado_org%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22Azure%20DevOps%20organization%20name%20(e.g.%20contoso)%22%7D%5D) [![Install Azure DevOps in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Azure_Devops_MCP_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=ado&quality=insiders&type=stdio&command=npx&args=%5B%22-y%22%2C%22%40azure-devops%2Fmcp%22%2C%22%24%7Binput%3Aado_org%7D%22%5D&inputs=%5B%7B%22id%22%3A%22ado_org%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22Azure%20DevOps%20organization%20name%20(e.g.%20contoso)%22%7D%5D) [![Install Azure DevOps in Visual Studio](https://img.shields.io/badge/Visual_Studio-Install_Azure_DevOps_MCP_Server-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://github.com/microsoft/azure-devops-mcp/blob/main/docs/GETTINGSTARTED.md#%EF%B8%8F-visual-studio-2022--github-copilot) +- **INSTALL**: [![Install Azure DevOps in VS Code](https://img.shields.io/badge/VS_Code-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=ado&type=stdio&command=npx&args=%5B%22-y%22%2C%22%40azure-devops%2Fmcp%22%2C%22%24%7Binput%3Aado_org%7D%22%5D&inputs=%5B%7B%22id%22%3A%22ado_org%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22Azure%20DevOps%20organization%20name%20(e.g.%20contoso)%22%7D%5D) [![Install Azure DevOps in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=ado&quality=insiders&type=stdio&command=npx&args=%5B%22-y%22%2C%22%40azure-devops%2Fmcp%22%2C%22%24%7Binput%3Aado_org%7D%22%5D&inputs=%5B%7B%22id%22%3A%22ado_org%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22Azure%20DevOps%20organization%20name%20(e.g.%20contoso)%22%7D%5D) [![Install Azure DevOps in Visual Studio](https://img.shields.io/badge/Visual_Studio-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://github.com/microsoft/azure-devops-mcp/blob/main/docs/GETTINGSTARTED.md#%EF%B8%8F-visual-studio-2022--github-copilot) ### ☸️ Azure Kubernetes Service (AKS) @@ -65,35 +66,35 @@ This repository contains core libraries, test frameworks, engineering systems, p - **DESCRIPTION**: An MCP server that enables AI assistants to interact with Azure Kubernetes Service (AKS) clusters. It serves as a bridge between AI tools and AKS, translating natural language requests into AKS operations and returning the results in a format the AI tools can understand. - **CATEGORY**: `CLOUD AND INFRASTRUCTURE` - **TYPE**: `Local` -- **INSTALL**: [![Install AKS MCP in VS Code](https://img.shields.io/badge/VS_Code-Install_AKS_MCP_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect?url=vscode:extension/ms-kubernetes-tools.vscode-aks-tools) [![Install AKS MCP in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_AKS_MCP_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect?url=vscode-insiders:extension/ms-kubernetes-tools.vscode-aks-tools) [![Install AKS MCP in Visual Studio](https://img.shields.io/badge/Visual_Studio-Install_AKS_MCP_Server-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://github.com/Azure/aks-mcp) +- **INSTALL**: [![Install AKS MCP in VS Code](https://img.shields.io/badge/VS_Code-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect?url=vscode:extension/ms-kubernetes-tools.vscode-aks-tools) [![Install AKS MCP in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect?url=vscode-insiders:extension/ms-kubernetes-tools.vscode-aks-tools) [![Install AKS MCP in Visual Studio](https://img.shields.io/badge/Visual_Studio-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://github.com/Azure/aks-mcp) ### GitHub Logo GitHub - **REPOSITORY**: [github/github-mcp-server](https://github.com/github/github-mcp-server) - **DESCRIPTION**: Access GitHub repositories, issues, and pull requests through secure API integration. - **CATEGORY**: `DEVELOPER TOOLS` - **TYPE**: `REMOTE` - `https://api.githubcopilot.com/mcp` -- **INSTALL**: [![Install GitHub MCP in VS Code](https://img.shields.io/badge/VS_Code-Install_GitHub_MCP_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) [![Install GitHub MCP in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_GitHub_MCP_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D&quality=insiders) [![Install GitHub MCP in Visual Studio](https://img.shields.io/badge/Visual_Studio-Install_GitHub_MCP_Server-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22github%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) +- **INSTALL**: [![Install GitHub MCP in VS Code](https://img.shields.io/badge/VS_Code-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) [![Install GitHub MCP in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D&quality=insiders) [![Install GitHub MCP in Visual Studio](https://img.shields.io/badge/Visual_Studio-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22github%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) ### GitHub Logo GitHub Awesome-Copilot - **REPOSITORY**: [github/awesome-copilot](https://github.com/github/awesome-copilot) - **DESCRIPTION**: Community-contributed instructions, prompts, and configurations to help you make the most of GitHub Copilot. - **CATEGORY**: `DEVELOPER TOOLS` - **TYPE**: `Local` -- **INSTALL**: [![Install Awesome Copilot MCP in VS Code](https://img.shields.io/badge/VS_Code-Install_Awesome_Copilot_MCP_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/mcp/vscode) [![Install Awesome Copilot MCP in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Awesome_Copilot_MCP_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/mcp/vscode-insiders) [![Install in Visual Studio](https://img.shields.io/badge/Visual_Studio-Install_Awesome_Copilot_MCP_Server-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://aka.ms/awesome-copilot/mcp/vs) +- **INSTALL**: [![Install Awesome Copilot MCP in VS Code](https://img.shields.io/badge/VS_Code-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/mcp/vscode) [![Install Awesome Copilot MCP in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://aka.ms/awesome-copilot/mcp/vscode-insiders) [![Install in Visual Studio](https://img.shields.io/badge/Visual_Studio-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://aka.ms/awesome-copilot/mcp/vs) ### 📝 Markitdown - **REPOSITORY**: [microsoft/markitdown](https://github.com/microsoft/markitdown) - **DESCRIPTION**: A specialized MCP server for Markdown processing and manipulation. Enables AI models to read, write, and transform Markdown content with robust parsing and formatting capabilities. - **CATEGORY**: `DEVELOPER TOOLS` - **TYPE**: `Local` -- **INSTALL**: [![Install Markitdown MCP in VS Code](https://img.shields.io/badge/VS_Code-Install_Markitdown_MCP_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=ffffff)](https://vscode.dev/redirect?url=vscode:mcp/install?%7B%22name%22%3A%22markitdown%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22markitdown-mcp%22%5D%7D) [![Install Markitdown MCP in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Markitdown_MCP_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=ffffff)](https://vscode.dev/redirect?url=vscode-insiders:mcp/install?%7B%22name%22%3A%22markitdown%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22markitdown-mcp%22%5D%7D) [![Install Markitdown MCP in Visual Studio](https://img.shields.io/badge/Visual_Studio-Install_Markitdown_MCP_Server-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22markitdown%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22markitdown-mcp%22%5D%7D) +- **INSTALL**: [![Install Markitdown MCP in VS Code](https://img.shields.io/badge/VS_Code-0098FF?style=flat-square&logo=visualstudiocode&logoColor=ffffff)](https://vscode.dev/redirect?url=vscode:mcp/install?%7B%22name%22%3A%22markitdown%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22markitdown-mcp%22%5D%7D) [![Install Markitdown MCP in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=ffffff)](https://vscode.dev/redirect?url=vscode-insiders:mcp/install?%7B%22name%22%3A%22markitdown%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22markitdown-mcp%22%5D%7D) [![Install Markitdown MCP in Visual Studio](https://img.shields.io/badge/Visual_Studio-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22markitdown%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22markitdown-mcp%22%5D%7D) ### 💻 Microsoft 365 Agents Toolkit - **REPOSITORY**: [OfficeDev/microsoft-365-agents-toolkit](https://github.com/OfficeDev/microsoft-365-agents-toolkit/) - **DESCRIPTION**: The Microsoft 365 Agents Toolkit MCP Server is a Model Context Protocol (MCP) server that provides a seamless connection between AI agents and developers for building apps and agents for Microsoft 365 and Microsoft 365 Copilot. - **CATEGORY**: `DEVELOPER TOOLS` - **TYPE**: `Local` -- **INSTALL**: [![Install Microsoft 365 Agents Toolkit in VS Code](https://img.shields.io/badge/VS_Code-Install_Microsoft_365_Agents_Toolkit-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect?url=vscode:extension/TeamsDevApp.ms-teams-vscode-extension) [![Install Microsoft 365 Agents Toolkit in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Microsoft_365_Agents_Toolkit-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect?url=vscode-insiders:extension/TeamsDevApp.ms-teams-vscode-extension) +- **INSTALL**: [![Install Microsoft 365 Agents Toolkit in VS Code](https://img.shields.io/badge/VS_Code-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect?url=vscode:extension/TeamsDevApp.ms-teams-vscode-extension) [![Install Microsoft 365 Agents Toolkit in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect?url=vscode-insiders:extension/TeamsDevApp.ms-teams-vscode-extension) ### 📊 Microsoft Clarity - **REPOSITORY**: [microsoft/clarity-mcp-server](https://github.com/microsoft/clarity-mcp-server) @@ -114,7 +115,7 @@ This repository contains core libraries, test frameworks, engineering systems, p - **DESCRIPTION**: An MCP server for Microsoft Dev Box. Enables natural language interactions for developer-focused operations like managing Dev Boxes, configuring environments, and handling pools. - **CATEGORY**: `DEVELOPER TOOLS` - **TYPE**: `Local` -- **INSTALL**: [![Install Dev Box MCP in VS Code](https://img.shields.io/badge/VS_Code-Install_Dev_Box_MCP_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=DevBox&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40microsoft%2Fdevbox-mcp%40latest%22%5D%7D) [![Install Dev Box MCP in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Dev_Box_MCP_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=DevBox&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40microsoft%2Fdevbox-mcp%40latest%22%5D%7D&quality=insiders) [![Install Dev Box MCP in Visual Studio](https://img.shields.io/badge/Visual_Studio-Install_Dev_Box_MCP_Server-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22DevBox%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40microsoft%2Fdevbox-mcp%40latest%22%5D%7D) +- **INSTALL**: [![Install Dev Box MCP in VS Code](https://img.shields.io/badge/VS_Code-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=DevBox&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40microsoft%2Fdevbox-mcp%40latest%22%5D%7D) [![Install Dev Box MCP in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=DevBox&config=%7B%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40microsoft%2Fdevbox-mcp%40latest%22%5D%7D&quality=insiders) [![Install Dev Box MCP in Visual Studio](https://img.shields.io/badge/Visual_Studio-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22DevBox%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22-y%22%2C%22%40microsoft%2Fdevbox-mcp%40latest%22%5D%7D) ### Microsoft Fabric Logo Microsoft Fabric (Public Preview) - **REPOSITORY**: [microsoft/mcp](https://github.com/microsoft/mcp/tree/main/servers/Fabric.Mcp.Server#readme) @@ -128,21 +129,14 @@ This repository contains core libraries, test frameworks, engineering systems, p - **DESCRIPTION**: This server enables AI agents to interact with Fabric RTI services by providing tools through the MCP interface, allowing for seamless data querying and analysis capabilities. - **CATEGORY**: `DATA AND ANALYTICS` - **TYPE**: `Local` -- **INSTALL**: [![Install Fabric RTI MCP in VS Code](https://img.shields.io/badge/VS_Code-Install_Microsoft_Fabric_RTI_MCP_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=ms-fabric-rti&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22microsoft-fabric-rti-mcp%22%5D%7D) [![Install Fabric RTI MCP in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Microsoft_Fabric_RTI_MCP_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=ms-fabric-rti&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22microsoft-fabric-rti-mcp%22%5D%7D&quality=insiders) [![Install Fabric RTI MCP in Visual Studio](https://img.shields.io/badge/Visual_Studio-Install_Microsoft_Fabric_RTI_MCP_Server-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22ms-fabric-rti%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22microsoft-fabric-rti-mcp%22%5D%7D) - -### 📁 Microsoft Files -- **REPOSITORY**: [microsoft/files-mcp-server](https://github.com/microsoft/files-mcp-server) -- **DESCRIPTION**: Provides a declarative control plane for managing file-based resources, supporting AI workflows that involve static files and documentation synchronization. -- **CATEGORY**: `DEVELOPER TOOLS` -- **TYPE**: `Local` -- **INSTALL**: [microsoft/files-mcp-server](https://github.com/microsoft/files-mcp-server) +- **INSTALL**: [![Install Fabric RTI MCP in VS Code](https://img.shields.io/badge/VS_Code-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=ms-fabric-rti&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22microsoft-fabric-rti-mcp%22%5D%7D) [![Install Fabric RTI MCP in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=ms-fabric-rti&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22microsoft-fabric-rti-mcp%22%5D%7D&quality=insiders) [![Install Fabric RTI MCP in Visual Studio](https://img.shields.io/badge/Visual_Studio-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22ms-fabric-rti%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22microsoft-fabric-rti-mcp%22%5D%7D) ### 📚 Microsoft Learn - **REPOSITORY**: [microsoftdocs/mcp](https://github.com/microsoftdocs/mcp) - **DESCRIPTION**: AI assistant with real-time access to official Microsoft documentation. - **CATEGORY**: `PRODUCTIVITY` - **TYPE**: `REMOTE` - `https://learn.microsoft.com/api/mcp` -- **INSTALL**: [![Install Microsoft Learn MCP in VS Code](https://img.shields.io/badge/VS_Code-Install_Microsoft_Docs_MCP-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect/mcp/install?name=microsoft.docs.mcp&config=%7B%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Flearn.microsoft.com%2Fapi%2Fmcp%22%7D) [![Install Microsoft Learn MCP in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Microsoft_Docs_MCP-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=microsoft.docs.mcp&config=%7B%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Flearn.microsoft.com%2Fapi%2Fmcp%22%7D&quality=insiders) [![Install Microsoft Learn MCP in Visual Studio](https://img.shields.io/badge/Visual_Studio-Install_Microsoft_Docs_MCP-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22microsoft.docs.mcp%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Flearn.microsoft.com%2Fapi%2Fmcp%22%7D) +- **INSTALL**: [![Install Microsoft Learn MCP in VS Code](https://img.shields.io/badge/VS_Code-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://vscode.dev/redirect/mcp/install?name=microsoft.docs.mcp&config=%7B%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Flearn.microsoft.com%2Fapi%2Fmcp%22%7D) [![Install Microsoft Learn MCP in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=microsoft.docs.mcp&config=%7B%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Flearn.microsoft.com%2Fapi%2Fmcp%22%7D&quality=insiders) [![Install Microsoft Learn MCP in Visual Studio](https://img.shields.io/badge/Visual_Studio-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22microsoft.docs.mcp%22%2C%22type%22%3A%22http%22%2C%22url%22%3A%22https%3A%2F%2Flearn.microsoft.com%2Fapi%2Fmcp%22%7D) ### 🛢️ Microsoft SQL - **REPOSITORY**: [MSSQL MCP Server](https://aka.ms/MssqlMcp) @@ -163,7 +157,14 @@ This repository contains core libraries, test frameworks, engineering systems, p - **DESCRIPTION**: This server enables LLMs to interact with web pages through structured accessibility snapshots, bypassing the need for screenshots or visually-tuned models. - **CATEGORY**: `DEVELOPER TOOLS` - **TYPE**: `Local` -- **INSTALL**: [![Install Playwright MCP in VS Code](https://img.shields.io/badge/VS_Code-Install_Playwright_MCP-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [![Install Playwright MCP in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Playwright_MCP-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [![Install Playwright MCP in Visual Studio](https://img.shields.io/badge/Visual_Studio-Install_Playwright_MCP-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22playwright%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22%40playwright%2Fmcp%40latest%22%5D%7D) +- **INSTALL**: [![Install Playwright MCP in VS Code](https://img.shields.io/badge/VS_Code-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect?url=vscode%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [![Install Playwright MCP in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect?url=vscode-insiders%3Amcp%2Finstall%3F%257B%2522name%2522%253A%2522playwright%2522%252C%2522command%2522%253A%2522npx%2522%252C%2522args%2522%253A%255B%2522%2540playwright%252Fmcp%2540latest%2522%255D%257D) [![Install Playwright MCP in Visual Studio](https://img.shields.io/badge/Visual_Studio-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22playwright%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22npx%22%2C%22args%22%3A%5B%22%40playwright%2Fmcp%40latest%22%5D%7D) + +### 🧩 Wassette +- **REPOSITORY**: [microsoft/wassette](https://github.com/microsoft/wassette) +- **DESCRIPTION**: Wassette: A security-oriented runtime that runs WebAssembly Components via MCP. +- **CATEGORY**: `DEVELOPER TOOLS` +- **TYPE**: `Local` +- **INSTALL**: [microsoft/wassette](https://github.com/microsoft/wassette) ## 🏗️ Looking for starter templates that use MCP? diff --git a/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupListCommand.cs b/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupListCommand.cs index 56cb54cde1..f2c0aaf010 100644 --- a/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupListCommand.cs +++ b/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupListCommand.cs @@ -16,6 +16,8 @@ public sealed class GroupListCommand(ILogger logger) : Subscri private const string CommandTitle = "List Resource Groups"; private readonly ILogger _logger = logger; + public override string Id => "a0049f31-9a32-4b5e-91ec-e7b074fc7246"; + public override string Name => "list"; public override string Description => @@ -37,7 +39,7 @@ returned as a JSON array. Secret = false }; - public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) { if (!Validate(parseResult.CommandResult, context.Response).IsValid) { @@ -52,7 +54,8 @@ public override async Task ExecuteAsync(CommandContext context, var groups = await resourceGroupService.GetResourceGroups( options.Subscription!, options.Tenant, - options.RetryPolicy); + options.RetryPolicy, + cancellationToken); context.Response.Results = groups?.Count > 0 ? ResponseResult.Create(new Result(groups), GroupJsonContext.Default.Result) : diff --git a/core/Azure.Mcp.Core/src/Areas/Group/GroupSetup.cs b/core/Azure.Mcp.Core/src/Areas/Group/GroupSetup.cs index 56cc5008b8..560e81fece 100644 --- a/core/Azure.Mcp.Core/src/Areas/Group/GroupSetup.cs +++ b/core/Azure.Mcp.Core/src/Areas/Group/GroupSetup.cs @@ -11,6 +11,8 @@ public sealed class GroupSetup : IAreaSetup { public string Name => "group"; + public string Title => "Azure Resource Groups"; + public void ConfigureServices(IServiceCollection services) { services.AddSingleton(); @@ -18,7 +20,7 @@ public void ConfigureServices(IServiceCollection services) public CommandGroup RegisterCommands(IServiceProvider serviceProvider) { - var group = new CommandGroup(Name, "Resource group operations - Commands for listing and managing Azure resource groups in your subscriptions."); + var group = new CommandGroup(Name, "Resource group operations - Commands for listing and managing Azure resource groups in your subscriptions.", Title); // Register Group commands var listCommand = serviceProvider.GetRequiredService(); diff --git a/core/Azure.Mcp.Core/src/Areas/IAreaSetup.cs b/core/Azure.Mcp.Core/src/Areas/IAreaSetup.cs index b7c1dac349..296b412cf1 100644 --- a/core/Azure.Mcp.Core/src/Areas/IAreaSetup.cs +++ b/core/Azure.Mcp.Core/src/Areas/IAreaSetup.cs @@ -13,6 +13,11 @@ public interface IAreaSetup /// string Name { get; } + /// + /// Gets the user-friendly title of the area for display purposes. + /// + string Title { get; } + /// /// Configure any dependencies. /// diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/BaseDiscoveryStrategy.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/BaseDiscoveryStrategy.cs index 87c22f0d7b..0e37556ffa 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/BaseDiscoveryStrategy.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/BaseDiscoveryStrategy.cs @@ -19,23 +19,15 @@ public abstract class BaseDiscoveryStrategy(ILogger logger) : IMcpDiscoveryStrat /// /// Cache of MCP clients created by this discovery strategy, keyed by server name (case-insensitive). /// - protected readonly Dictionary _clientCache = new(StringComparer.OrdinalIgnoreCase); + protected readonly Dictionary _clientCache = new(StringComparer.OrdinalIgnoreCase); private bool _disposed = false; - /// - /// Discovers available MCP servers via the implementing strategy. - /// - /// A collection of discovered MCP server providers. - public abstract Task> DiscoverServersAsync(); + /// + public abstract Task> DiscoverServersAsync(CancellationToken cancellationToken); - /// - /// Finds a server provider by name from the discovered servers. - /// - /// The name of the server to find. - /// The server provider if found. - /// Thrown when no server with the specified name is found. - public async Task FindServerProviderAsync(string name) + /// + public async Task FindServerProviderAsync(string name, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(name, nameof(name)); if (string.IsNullOrWhiteSpace(name)) @@ -43,7 +35,7 @@ public async Task FindServerProviderAsync(string name) throw new ArgumentNullException(nameof(name), "Server name cannot be null or empty."); } - var serverProviders = await DiscoverServersAsync(); + var serverProviders = await DiscoverServersAsync(cancellationToken); foreach (var serverProvider in serverProviders) { var metadata = serverProvider.CreateMetadata(); @@ -56,15 +48,8 @@ public async Task FindServerProviderAsync(string name) throw new KeyNotFoundException($"No MCP server found with the name '{name}'."); } - /// - /// Gets an existing MCP client from the cache or creates a new one if not found. - /// - /// The name of the server to get or create a client for. - /// Optional client configuration options. If null, default options are used. - /// An MCP client that can communicate with the specified server. - /// Thrown when the name parameter is null. - /// Thrown when no server with the specified name is found. - public async Task GetOrCreateClientAsync(string name, McpClientOptions? clientOptions = null) + /// + public async Task GetOrCreateClientAsync(string name, McpClientOptions? clientOptions = null, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(name, nameof(name)); if (string.IsNullOrWhiteSpace(name)) @@ -77,8 +62,8 @@ public async Task GetOrCreateClientAsync(string name, McpClientOptio return client; } - var serverProvider = await FindServerProviderAsync(name); - client = await serverProvider.CreateClientAsync(clientOptions ?? new McpClientOptions()); + var serverProvider = await FindServerProviderAsync(name, cancellationToken); + client = await serverProvider.CreateClientAsync(clientOptions ?? new McpClientOptions(), cancellationToken); _clientCache[name] = client; return client; diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategy.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategy.cs index cb5ac720ab..d3e7ebe065 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategy.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategy.cs @@ -19,7 +19,6 @@ public sealed class CommandGroupDiscoveryStrategy(CommandFactory commandFactory, { private readonly CommandFactory _commandFactory = commandFactory; private readonly IOptions _options = options; - private static readonly List IgnoreCommandGroups = ["extension", "server", "tools"]; /// /// Gets or sets the entry point to use for the command group servers. @@ -27,14 +26,11 @@ public sealed class CommandGroupDiscoveryStrategy(CommandFactory commandFactory, /// public string? EntryPoint { get; set; } = null; - /// - /// Discovers available command groups and converts them to MCP server providers. - /// - /// A collection of command group server providers. - public override Task> DiscoverServersAsync() + /// + public override Task> DiscoverServersAsync(CancellationToken cancellationToken) { var providers = _commandFactory.RootGroup.SubGroup - .Where(group => !IgnoreCommandGroups.Contains(group.Name, StringComparer.OrdinalIgnoreCase)) + .Where(group => !DiscoveryConstants.IgnoredCommandGroups.Contains(group.Name, StringComparer.OrdinalIgnoreCase)) .Where(group => _options.Value.Namespace == null || _options.Value.Namespace.Length == 0 || _options.Value.Namespace.Contains(group.Name, StringComparer.OrdinalIgnoreCase)) diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupServerProvider.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupServerProvider.cs index 93e03c7cf8..99972f6f65 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupServerProvider.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupServerProvider.cs @@ -32,10 +32,8 @@ public string? EntryPoint /// public bool ReadOnly { get; set; } = false; - /// - /// Creates an MCP client from a command group. - /// - public async Task CreateClientAsync(McpClientOptions clientOptions) + /// + public async Task CreateClientAsync(McpClientOptions clientOptions, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(EntryPoint)) { @@ -52,7 +50,7 @@ public async Task CreateClientAsync(McpClientOptions clientOptions) }; var clientTransport = new StdioClientTransport(transportOptions); - return await McpClientFactory.CreateAsync(clientTransport, clientOptions); + return await McpClient.CreateAsync(clientTransport, clientOptions, cancellationToken: cancellationToken); } /// @@ -80,6 +78,7 @@ public McpServerMetadata CreateMetadata() { Id = _commandGroup.Name, Name = _commandGroup.Name, + Title = _commandGroup.Title ?? _commandGroup.Name, Description = _commandGroup.Description }; } diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CompositeDiscoveryStrategy.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CompositeDiscoveryStrategy.cs index 75e41e4b8d..3b63315bc3 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CompositeDiscoveryStrategy.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CompositeDiscoveryStrategy.cs @@ -36,13 +36,10 @@ private static List InitializeStrategies(IEnumerable - /// Discovers available MCP servers from all combined discovery strategies. - /// - /// A collection of all discovered MCP server providers from all strategies. - public override async Task> DiscoverServersAsync() + /// + public override async Task> DiscoverServersAsync(CancellationToken cancellationToken) { - var tasks = _strategies.Select(strategy => strategy.DiscoverServersAsync()); + var tasks = _strategies.Select(strategy => strategy.DiscoverServersAsync(cancellationToken)); var results = await Task.WhenAll(tasks); return results.SelectMany(result => result); diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolDiscoveryStrategy.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolDiscoveryStrategy.cs new file mode 100644 index 0000000000..effb5d5531 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolDiscoveryStrategy.cs @@ -0,0 +1,297 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using System.Text.Json; +using Azure.Mcp.Core.Areas; +using Azure.Mcp.Core.Areas.Server.Models; +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Configuration; +using Azure.Mcp.Core.Services.Telemetry; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; + +/// +/// Discovery strategy that exposes command groups as MCP servers. +/// This strategy converts Azure CLI command groups into MCP servers, allowing them to be accessed via the MCP protocol. +/// +/// The command factory used to access available command groups. +/// Options for configuring the service behavior. +/// Configuration options for the Azure MCP server. +/// Logger instance for this discovery strategy. +public sealed class ConsolidatedToolDiscoveryStrategy(CommandFactory commandFactory, IServiceProvider serviceProvider, IOptions options, IOptions configurationOptions, ILogger logger) : BaseDiscoveryStrategy(logger) +{ + private readonly CommandFactory _commandFactory = commandFactory; + private readonly IServiceProvider _serviceProvider = serviceProvider; + private readonly IOptions _options = options; + private readonly IOptions _configurationOptions = configurationOptions; + private CommandFactory? _consolidatedCommandFactory; + + /// + /// Gets or sets the entry point to use for the command group servers. + /// This can be used to specify a custom entry point for the commands. + /// + public string? EntryPoint { get; set; } = null; + public static readonly string[] IgnoredCommandGroups = ["server", "tools"]; + + /// + /// Creates a new CommandFactory with consolidated command groups. + /// This method builds command groups from the consolidated tools definition + /// without mutating the original CommandFactory. + /// + /// A new CommandFactory instance with consolidated command groups. + public CommandFactory CreateConsolidatedCommandFactory() + { + if (_consolidatedCommandFactory != null) + { + return _consolidatedCommandFactory; + } + + // Load consolidated tool definitions from JSON file + var consolidatedTools = LoadConsolidatedToolDefinitions(); + + // Filter commands based on options + var allCommands = _commandFactory.AllCommands; + var filteredCommands = FilterCommands(allCommands); + + // Create individual area setups for each consolidated tool + // This way, each consolidated tool becomes a top-level namespace + var consolidatedAreas = new List(); + + var unmatchedCommands = new HashSet(filteredCommands.Keys, StringComparer.OrdinalIgnoreCase); + + foreach (var consolidatedTool in consolidatedTools) + { + var matchingCommands = filteredCommands + .Where(kvp => consolidatedTool.MappedToolList != null && + consolidatedTool.MappedToolList.Contains(kvp.Key, StringComparer.OrdinalIgnoreCase)) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + + if (matchingCommands.Count == 0) + { + continue; + } + +#if DEBUG + // In debug mode, validate that all tools in MappedToolList found a match when conditions are met + if (_options.Value.ReadOnly == false && (_options.Value.Namespace == null || _options.Value.Namespace.Length == 0)) + { + if (consolidatedTool.MappedToolList != null) + { + var matchedToolNames = new HashSet(matchingCommands.Keys, StringComparer.OrdinalIgnoreCase); + var unmatchedToolsInList = consolidatedTool.MappedToolList + .Where(toolName => !matchedToolNames.Contains(toolName)) + .ToList(); + + if (unmatchedToolsInList.Count > 0) + { + var unmatchedToolsList = string.Join(", ", unmatchedToolsInList); + var errorMessage = $"Consolidated tool '{consolidatedTool.Name}' has {unmatchedToolsInList.Count} tools in MappedToolList that didn't find a match in filteredCommands: {unmatchedToolsList}"; + _logger.LogError(errorMessage); + throw new InvalidOperationException(errorMessage); + } + } + } +#endif + + // Validate metadata for each command + foreach (var (commandName, command) in matchingCommands) + { + if (!AreMetadataEqual(command.Metadata, consolidatedTool.ToolMetadata)) + { + var errorMessage = $"Command '{commandName}' has mismatched ToolMetadata for consolidated tool '{consolidatedTool.Name}'. " + + $"Command metadata: [Destructive={command.Metadata.Destructive}, Idempotent={command.Metadata.Idempotent}, " + + $"OpenWorld={command.Metadata.OpenWorld}, ReadOnly={command.Metadata.ReadOnly}, Secret={command.Metadata.Secret}, " + + $"LocalRequired={command.Metadata.LocalRequired}], " + + $"Consolidated tool metadata: [Destructive={consolidatedTool.ToolMetadata?.Destructive}, " + + $"Idempotent={consolidatedTool.ToolMetadata?.Idempotent}, OpenWorld={consolidatedTool.ToolMetadata?.OpenWorld}, " + + $"ReadOnly={consolidatedTool.ToolMetadata?.ReadOnly}, Secret={consolidatedTool.ToolMetadata?.Secret}, " + + $"LocalRequired={consolidatedTool.ToolMetadata?.LocalRequired}]"; +#if DEBUG + _logger.LogError(errorMessage); + throw new InvalidOperationException(errorMessage); +#else + _logger.LogWarning(errorMessage); +#endif + } + + unmatchedCommands.Remove(commandName); + } + + // Create an area setup for this consolidated tool + var area = new SingleConsolidatedToolAreaSetup( + consolidatedTool, + matchingCommands + ); + + consolidatedAreas.Add(area); + } + +#if DEBUG + // Check for unmatched commands + if (unmatchedCommands.Count > 0) + { + var unmatchedList = string.Join(", ", unmatchedCommands.OrderBy(c => c)); + var errorMessage = $"Found {unmatchedCommands.Count} unmatched commands: {unmatchedList}"; + _logger.LogError(errorMessage); + throw new InvalidOperationException(errorMessage); + } +#else + if (unmatchedCommands.Count > 0) + { + var unmatchedList = string.Join(", ", unmatchedCommands.OrderBy(c => c)); + _logger.LogWarning("Found {Count} unmatched commands: {Commands}", unmatchedCommands.Count, unmatchedList); + } +#endif + + // Create a new CommandFactory with all consolidated areas + var telemetryService = _serviceProvider.GetRequiredService(); + var factoryLogger = _serviceProvider.GetRequiredService>(); + + _consolidatedCommandFactory = new CommandFactory( + _serviceProvider, + consolidatedAreas, + telemetryService, + _configurationOptions, + factoryLogger + ); + + return _consolidatedCommandFactory; + } + + private List LoadConsolidatedToolDefinitions() + { + try + { + var assembly = Assembly.GetExecutingAssembly(); + var resourceName = "Azure.Mcp.Core.Areas.Server.Resources.consolidated-tools.json"; + using var stream = assembly.GetManifestResourceStream(resourceName); + if (stream == null) + { + var errorMessage = $"Failed to load embedded resource '{resourceName}'"; + _logger.LogError(errorMessage); + throw new InvalidOperationException(errorMessage); + } + + using var reader = new StreamReader(stream); + var json = reader.ReadToEnd(); + using var jsonDoc = JsonDocument.Parse(json); + if (!jsonDoc.RootElement.TryGetProperty("consolidated_tools", out var toolsArray)) + { + var errorMessage = "Property 'consolidated_tools' not found in consolidated-tools.json"; + _logger.LogError(errorMessage); + throw new InvalidOperationException(errorMessage); + } + + return JsonSerializer.Deserialize(toolsArray.GetRawText(), ServerJsonContext.Default.ListConsolidatedToolDefinition) ?? new List(); + } + catch (Exception ex) + { + var errorMessage = "Failed to load consolidated tools from JSON file"; + _logger.LogError(ex, errorMessage); + throw new InvalidOperationException(errorMessage); + } + } + + private Dictionary FilterCommands(IReadOnlyDictionary allCommands) + { + return allCommands + .Where(kvp => + { + var serviceArea = _commandFactory.GetServiceArea(kvp.Key); + return serviceArea == null || !IgnoredCommandGroups.Contains(serviceArea, StringComparer.OrdinalIgnoreCase); + }) + .Where(kvp => _options.Value.ReadOnly == false || kvp.Value.Metadata.ReadOnly == true) + .Where(kvp => + { + // Filter by namespace if specified + if (_options.Value.Namespace == null || _options.Value.Namespace.Length == 0) + { + return true; + } + var serviceArea = _commandFactory.GetServiceArea(kvp.Key); + return serviceArea != null && _options.Value.Namespace.Contains(serviceArea, StringComparer.OrdinalIgnoreCase); + }) + .ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + } + + /// + public override Task> DiscoverServersAsync(CancellationToken cancellationToken) + { + return Task.FromResult>(new List()); + } + + /// + /// Compares two ToolMetadata objects for equality. + /// + /// The first ToolMetadata to compare. + /// The second ToolMetadata to compare. + /// True if the metadata objects are equal, false otherwise. + internal static bool AreMetadataEqual(ToolMetadata? metadata1, ToolMetadata? metadata2) + { + if (metadata1 == null && metadata2 == null) + { + return true; + } + + if (metadata1 == null || metadata2 == null) + { + return false; + } + + return metadata1.Destructive == metadata2.Destructive && + metadata1.Idempotent == metadata2.Idempotent && + metadata1.OpenWorld == metadata2.OpenWorld && + metadata1.ReadOnly == metadata2.ReadOnly && + metadata1.Secret == metadata2.Secret && + metadata1.LocalRequired == metadata2.LocalRequired; + } +} + +/// +/// Represents a single consolidated tool as an IAreaSetup. +/// Each instance creates a top-level namespace for one consolidated tool in the CommandFactory. +/// This allows NamespaceToolLoader to see each consolidated tool as a separate top-level namespace. +/// +internal sealed class SingleConsolidatedToolAreaSetup : IAreaSetup +{ + private readonly ConsolidatedToolDefinition _consolidatedTool; + private readonly Dictionary _matchingCommands; + + public SingleConsolidatedToolAreaSetup( + ConsolidatedToolDefinition consolidatedTool, + Dictionary matchingCommands) + { + _consolidatedTool = consolidatedTool; + _matchingCommands = matchingCommands; + } + + public string Name => _consolidatedTool.Name ?? string.Empty; + public string Title => Name; + + public void ConfigureServices(IServiceCollection services) + { + // No additional services needed + } + + public CommandGroup RegisterCommands(IServiceProvider serviceProvider) + { + // Create command group for this consolidated tool + var commandGroup = new CommandGroup(Name, Title); + + // Add all matching commands to this group + foreach (var cmd in _matchingCommands) + { + commandGroup.AddCommand(cmd.Key, cmd.Value); + } + + // Set tool metadata from the consolidated tool definition + commandGroup.ToolMetadata = _consolidatedTool.ToolMetadata; + + return commandGroup; + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolServerProvider.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolServerProvider.cs new file mode 100644 index 0000000000..e8fec77396 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolServerProvider.cs @@ -0,0 +1,94 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Commands; +using ModelContextProtocol.Client; + +namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; + +/// +/// Server provider that starts the azmcp server in "all" mode while explicitly +/// enumerating each tool (command) in a command group using repeated --tool flags. +/// This allows selective exposure of only the commands that belong to the provided group +/// without relying on the namespace grouping mechanism. +/// +public sealed class ConsolidatedToolServerProvider(CommandGroup commandGroup) : IMcpServerProvider +{ + private readonly CommandGroup _commandGroup = commandGroup; + private string? _entryPoint = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName; + + /// + /// Gets or sets the entry point executable path for the MCP server. + /// If set to null or empty, defaults to the current process executable. + /// + public string? EntryPoint + { + get => _entryPoint; + set => _entryPoint = string.IsNullOrWhiteSpace(value) + ? System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName + : value; + } + + /// + /// Gets or sets whether the MCP server should run in read-only mode. + /// + public bool ReadOnly { get; set; } = false; + + /// + public async Task CreateClientAsync(McpClientOptions clientOptions, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(EntryPoint)) + { + throw new InvalidOperationException("EntryPoint must be set before creating the MCP client."); + } + + var arguments = BuildArguments(); + + var transportOptions = new StdioClientTransportOptions + { + Name = _commandGroup.Name, + Command = EntryPoint, + Arguments = arguments, + }; + + var clientTransport = new StdioClientTransport(transportOptions); + return await McpClient.CreateAsync(clientTransport, clientOptions, cancellationToken: cancellationToken); + } + + /// + /// Builds the command-line arguments for the MCP server process. + /// Pattern: server start --mode all (--tool )+ [--read-only] + /// + internal string[] BuildArguments() + { + var arguments = new List { "server", "start", "--mode", "all" }; + + foreach (var kvp in _commandGroup.Commands) + { + arguments.Add("--tool"); + arguments.Add(kvp.Key); + } + + if (ReadOnly) + { + arguments.Add($"--{ServiceOptionDefinitions.ReadOnlyName}"); + } + + return [.. arguments]; + } + + /// + /// Creates metadata for the MCP server provider based on the command group. + /// + public McpServerMetadata CreateMetadata() + { + return new McpServerMetadata + { + Id = _commandGroup.Name, + Name = _commandGroup.Name, + Description = _commandGroup.Description, + ToolMetadata = _commandGroup.ToolMetadata, + }; + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/DiscoveryConstants.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/DiscoveryConstants.cs new file mode 100644 index 0000000000..1d9a77fedf --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/DiscoveryConstants.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; + +/// +/// Constants used by discovery strategies and tool loaders. +/// +public static class DiscoveryConstants +{ + /// + /// Utility namespaces that should be exposed as individual commands rather than namespace proxies. + /// These commands are always available in namespace mode regardless of namespace filters. + /// + public static readonly string[] UtilityNamespaces = ["subscription", "group"]; + + /// + /// Command groups that should be ignored when discovering namespace proxy servers. + /// These include both core infrastructure groups and utility groups that are handled separately. + /// + public static readonly string[] IgnoredCommandGroups = ["extension", "server", "tools", .. UtilityNamespaces]; +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/IDiscoveryStrategy.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/IDiscoveryStrategy.cs index 1b072d8202..6d8b1e67d1 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/IDiscoveryStrategy.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/IDiscoveryStrategy.cs @@ -10,24 +10,27 @@ public interface IMcpDiscoveryStrategy : IAsyncDisposable /// /// Discovers available MCP servers via this strategy. /// + /// A cancellation token. /// A collection of discovered MCP servers. - Task> DiscoverServersAsync(); + Task> DiscoverServersAsync(CancellationToken cancellationToken); /// /// Finds a server provider by name. /// /// The name of the server to find. + /// A cancellation token. /// The server provider if found. /// Thrown when no server with the specified name is found. - Task FindServerProviderAsync(string name); + Task FindServerProviderAsync(string name, CancellationToken cancellationToken); /// /// Gets an MCP client for the specified server. /// /// The name of the server to get a client for. /// Optional client configuration options. If null, default options are used. + /// A cancellation token. /// An MCP client that can communicate with the specified server. /// Thrown when no server with the specified name is found. /// Thrown when the name parameter is null. - Task GetOrCreateClientAsync(string name, McpClientOptions? clientOptions = null); + Task GetOrCreateClientAsync(string name, McpClientOptions? clientOptions = null, CancellationToken cancellationToken = default); } diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/McpServerMetadata.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/McpServerMetadata.cs index ac3f21dd51..9ff0009574 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/McpServerMetadata.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/McpServerMetadata.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Azure.Mcp.Core.Commands; using ModelContextProtocol.Client; namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; @@ -23,10 +24,20 @@ public sealed class McpServerMetadata(string id = "", string name = "", string d /// public string Name { get; set; } = name; + /// + /// Gets or sets the user-friendly title of the server for display purposes. + /// + public string? Title { get; set; } + /// /// Gets or sets a description of the server's purpose or capabilities. /// public string Description { get; set; } = description; + + /// + /// Gets or sets the tool metadata for this server, containing tool-specific information. + /// + public ToolMetadata? ToolMetadata { get; set; } } /// @@ -44,6 +55,9 @@ public interface IMcpServerProvider /// Creates an MCP client that can communicate with this server. /// /// Options to configure the client behavior. + /// A token to cancel the operation. /// A configured MCP client ready for use. - Task CreateClientAsync(McpClientOptions clientOptions); + /// Thrown when the server configuration doesn't specify a valid transport type (missing URL or stdio configuration). + /// Thrown when the server configuration is valid but client creation fails (e.g., missing command for stdio transport, dependency issues, or external process failures). + Task CreateClientAsync(McpClientOptions clientOptions, CancellationToken cancellationToken); } diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategy.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategy.cs index 9539df18bd..f660392048 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategy.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategy.cs @@ -18,11 +18,9 @@ namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; public sealed class RegistryDiscoveryStrategy(IOptions options, ILogger logger) : BaseDiscoveryStrategy(logger) { private readonly IOptions _options = options; - /// - /// Discovers available MCP servers from the embedded registry. - /// - /// A collection of server providers defined in the registry. - public override async Task> DiscoverServersAsync() + + /// + public override async Task> DiscoverServersAsync(CancellationToken cancellationToken) { var registryRoot = await LoadRegistryAsync(); if (registryRoot == null) diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs index f3a79c3e8e..dac744e633 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs @@ -27,29 +27,51 @@ public McpServerMetadata CreateMetadata() { Id = _id, Name = _id, + Title = _serverInfo.Title, Description = _serverInfo.Description ?? string.Empty }; } - /// - /// Creates an MCP client for this registry-based server. - /// - /// Options to configure the client behavior. - /// A configured MCP client ready for use. - /// Thrown when the server configuration doesn't specify a valid transport mechanism. - public async Task CreateClientAsync(McpClientOptions clientOptions) + /// + public async Task CreateClientAsync(McpClientOptions clientOptions, CancellationToken cancellationToken) { + Func>? clientFactory = null; + + // Determine which factory function to use based on configuration if (!string.IsNullOrWhiteSpace(_serverInfo.Url)) { - return await CreateSseClientAsync(clientOptions); + clientFactory = CreateHttpClientAsync; } else if (!string.IsNullOrWhiteSpace(_serverInfo.Type) && _serverInfo.Type.Equals("stdio", StringComparison.OrdinalIgnoreCase)) { - return await CreateStdioClientAsync(clientOptions); + clientFactory = CreateStdioClientAsync; } - else + + if (clientFactory == null) + { + throw new ArgumentException($"Registry server '{_id}' does not have a valid transport type. Either 'url' for HTTP transport or 'type=stdio' with 'command' must be specified."); + } + + try { - throw new InvalidOperationException($"Registry server '{_id}' does not have a valid url or type for transport."); + return await clientFactory(clientOptions, cancellationToken); + } + catch (Exception ex) + { + if (!string.IsNullOrWhiteSpace(_serverInfo.InstallInstructions)) + { + var errorWithInstructions = $""" + Failed to initialize the '{_id}' MCP tool. + This tool may require dependencies that are not installed. + + Installation Instructions: + {_serverInfo.InstallInstructions} + """; + + throw new InvalidOperationException(errorWithInstructions.Trim(), ex); + } + + throw new InvalidOperationException($"Failed to create MCP client for registry server '{_id}': {ex.Message}", ex); } } @@ -57,26 +79,28 @@ public async Task CreateClientAsync(McpClientOptions clientOptions) /// Creates an MCP client that communicates with the server using Server-Sent Events (SSE). /// /// Options to configure the client behavior. + /// A token to cancel the operation. /// A configured MCP client using SSE transport. - private async Task CreateSseClientAsync(McpClientOptions clientOptions) + private async Task CreateHttpClientAsync(McpClientOptions clientOptions, CancellationToken cancellationToken) { - var transportOptions = new SseClientTransportOptions + var transportOptions = new HttpClientTransportOptions { Name = _id, Endpoint = new Uri(_serverInfo.Url!), TransportMode = HttpTransportMode.AutoDetect, }; - var clientTransport = new SseClientTransport(transportOptions); - return await McpClientFactory.CreateAsync(clientTransport, clientOptions); + var clientTransport = new HttpClientTransport(transportOptions); + return await McpClient.CreateAsync(clientTransport, clientOptions, cancellationToken: cancellationToken); } /// /// Creates an MCP client that communicates with the server using stdio (standard input/output). /// /// Options to configure the client behavior. + /// A token to cancel the operation. /// A configured MCP client using stdio transport. /// Thrown when the server configuration doesn't specify a valid command for stdio transport. - private async Task CreateStdioClientAsync(McpClientOptions clientOptions) + private async Task CreateStdioClientAsync(McpClientOptions clientOptions, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(_serverInfo.Command)) { @@ -105,6 +129,6 @@ private async Task CreateStdioClientAsync(McpClientOptions clientOpt }; var clientTransport = new StdioClientTransport(transportOptions); - return await McpClientFactory.CreateAsync(clientTransport, clientOptions); + return await McpClient.CreateAsync(clientTransport, clientOptions, cancellationToken: cancellationToken); } } diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Runtime/McpRuntime.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Runtime/McpRuntime.cs index 5dd811128c..9a9bdfcb27 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Runtime/McpRuntime.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Runtime/McpRuntime.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Diagnostics; +using System.Text.Json.Nodes; using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; using Azure.Mcp.Core.Areas.Server.Options; using Azure.Mcp.Core.Helpers; @@ -57,11 +58,10 @@ public McpRuntime( /// A result containing the output of the tool invocation. public async ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken) { - using var activity = await _telemetry.StartActivity(ActivityName.ToolExecuted, request?.Server?.ClientInfo); + using var activity = _telemetry.StartActivity(ActivityName.ToolExecuted, request.Server.ClientInfo); + CaptureToolCallMeta(activity, request.Params?.Meta); - Activity.Current = activity; - - if (request?.Params == null) + if (request.Params == null) { var content = new TextContentBlock { @@ -125,6 +125,22 @@ public async ValueTask CallToolHandler(RequestContext CallToolHandler(RequestContext()); + } + var vsCodeRequestIdNode = meta["vscode.requestId"]; + if (vsCodeRequestIdNode != null && vsCodeRequestIdNode.GetValueKind() == JsonValueKind.String) + { + activity.AddTag(TagName.VSCodeRequestId, vsCodeRequestIdNode.GetValue()); + } + } + } + /// /// Delegates tool discovery requests to the configured tool loader. /// @@ -142,7 +175,7 @@ public async ValueTask CallToolHandler(RequestContextA result containing the list of available tools. public async ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken) { - using var activity = await _telemetry.StartActivity(ActivityName.ListToolsHandler, request?.Server?.ClientInfo); + using var activity = _telemetry.StartActivity(ActivityName.ListToolsHandler, request.Server.ClientInfo); try { diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceCollectionExtensions.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceCollectionExtensions.cs index 85796a85f4..39cd160cf4 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceCollectionExtensions.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceCollectionExtensions.cs @@ -7,7 +7,10 @@ using Azure.Mcp.Core.Areas.Server.Commands.Runtime; using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Configuration; using Azure.Mcp.Core.Helpers; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -22,10 +25,8 @@ namespace Azure.Mcp.Core.Areas.Server.Commands; /// /// Extension methods for configuring Azure MCP server services. /// -public static class AzureMcpServiceCollectionExtensions +public static class ServiceCollectionExtensions { - private const string DefaultServerName = "Azure MCP Server"; - /// /// Adds the Azure MCP server services to the specified . /// @@ -47,6 +48,7 @@ public static IServiceCollection AddAzureMcpServer(this IServiceCollection servi Namespace = serviceStartOptions.Namespace, ReadOnly = serviceStartOptions.ReadOnly ?? false, InsecureDisableElicitation = serviceStartOptions.InsecureDisableElicitation, + Tool = serviceStartOptions.Tool, }; if (serviceStartOptions.Mode == ModeTypes.NamespaceProxy) @@ -62,33 +64,24 @@ public static IServiceCollection AddAzureMcpServer(this IServiceCollection servi // Register tool loader strategies services.AddSingleton(); - services.AddSingleton(sp => - { - return new RegistryToolLoader( - sp.GetRequiredService(), - sp.GetRequiredService>(), - sp.GetRequiredService>() - ); - }); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); // Register server discovery strategies services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); - - // Register server providers - services.AddSingleton(); - services.AddSingleton(); + services.AddSingleton(); // Register MCP runtimes services.AddSingleton(); // Register MCP discovery strategies based on proxy mode - if (serviceStartOptions.Mode == ModeTypes.SingleToolProxy || serviceStartOptions.Mode == ModeTypes.NamespaceProxy) + if (serviceStartOptions.Mode == ModeTypes.SingleToolProxy) { services.AddSingleton(sp => { @@ -102,6 +95,24 @@ public static IServiceCollection AddAzureMcpServer(this IServiceCollection servi return new CompositeDiscoveryStrategy(discoveryStrategies, logger); }); } + else if (serviceStartOptions.Mode == ModeTypes.NamespaceProxy) + { + services.AddSingleton(); + } + else if (serviceStartOptions.Mode == ModeTypes.ConsolidatedProxy) + { + services.AddSingleton(sp => + { + var discoveryStrategies = new List + { + sp.GetRequiredService(), + sp.GetRequiredService(), + }; + + var logger = sp.GetRequiredService>(); + return new CompositeDiscoveryStrategy(discoveryStrategies, logger); + }); + } // Configure tool loading based on mode if (serviceStartOptions.Mode == ModeTypes.SingleToolProxy) @@ -115,9 +126,32 @@ public static IServiceCollection AddAzureMcpServer(this IServiceCollection servi var loggerFactory = sp.GetRequiredService(); var toolLoaders = new List { - sp.GetRequiredService(), + // ServerToolLoader with RegistryDiscoveryStrategy creates proxy tools for external MCP servers. + new ServerToolLoader( + sp.GetRequiredService(), + sp.GetRequiredService>(), + loggerFactory.CreateLogger() + ), + // NamespaceToolLoader enables direct in-process execution for tools in Azure namespaces + sp.GetRequiredService(), }; + // Always add utility commands (subscription, group) in namespace mode + // so they are available regardless of which namespaces are loaded + var utilityToolLoaderOptions = new ToolLoaderOptions( + Namespace: Discovery.DiscoveryConstants.UtilityNamespaces, + ReadOnly: defaultToolLoaderOptions.ReadOnly, + InsecureDisableElicitation: defaultToolLoaderOptions.InsecureDisableElicitation, + Tool: defaultToolLoaderOptions.Tool + ); + + toolLoaders.Add(new CommandFactoryToolLoader( + sp, + sp.GetRequiredService(), + Options.Create(utilityToolLoaderOptions), + loggerFactory.CreateLogger() + )); + // Append extension commands when no other namespaces are specified. if (defaultToolLoaderOptions.Namespace?.SequenceEqual(["extension"]) == true) { @@ -127,6 +161,37 @@ public static IServiceCollection AddAzureMcpServer(this IServiceCollection servi return new CompositeToolLoader(toolLoaders, loggerFactory.CreateLogger()); }); } + else if (serviceStartOptions.Mode == ModeTypes.ConsolidatedProxy) + { + services.AddSingleton(sp => + { + var loggerFactory = sp.GetRequiredService(); + var consolidatedStrategy = sp.GetRequiredService(); + + // Create a new CommandFactory with consolidated command groups + var consolidatedCommandFactory = consolidatedStrategy.CreateConsolidatedCommandFactory(); + + var toolLoaders = new List + { + // ServerToolLoader with RegistryDiscoveryStrategy creates proxy tools for external MCP servers. + new ServerToolLoader( + sp.GetRequiredService(), + sp.GetRequiredService>(), + loggerFactory.CreateLogger() + ), + // NamespaceToolLoader enables direct in-process execution for consolidated tools + new NamespaceToolLoader( + consolidatedCommandFactory, + sp.GetRequiredService>(), + sp, + loggerFactory.CreateLogger(), + false + ), + }; + + return new CompositeToolLoader(toolLoaders, loggerFactory.CreateLogger()); + }); + } else if (serviceStartOptions.Mode == ModeTypes.All) { services.AddSingleton(); @@ -145,27 +210,21 @@ public static IServiceCollection AddAzureMcpServer(this IServiceCollection servi var mcpServerOptions = services .AddOptions() - .Configure((mcpServerOptions, mcpRuntime) => + .Configure>((mcpServerOptions, mcpRuntime, serverConfiguration) => { - var mcpServerOptionsBuilder = services.AddOptions(); - var entryAssembly = Assembly.GetEntryAssembly(); - var assemblyName = entryAssembly?.GetName(); - var serverName = entryAssembly?.GetCustomAttribute()?.Title ?? DefaultServerName; + var configuration = serverConfiguration.Value; mcpServerOptions.ProtocolVersion = "2024-11-05"; mcpServerOptions.ServerInfo = new Implementation { - Name = serverName, - Version = assemblyName?.Version?.ToString() ?? "1.0.0-beta" + Name = configuration.DisplayName, + Version = configuration.Version, }; - mcpServerOptions.Capabilities = new ServerCapabilities + mcpServerOptions.Handlers = new() { - Tools = new ToolsCapability() - { - CallToolHandler = mcpRuntime.CallToolHandler, - ListToolsHandler = mcpRuntime.ListToolsHandler, - } + CallToolHandler = mcpRuntime.CallToolHandler, + ListToolsHandler = mcpRuntime.ListToolsHandler, }; // Add instructions for the server @@ -174,7 +233,7 @@ public static IServiceCollection AddAzureMcpServer(this IServiceCollection servi var mcpServerBuilder = services.AddMcpServer(); - if (serviceStartOptions.EnableInsecureTransports) + if (serviceStartOptions.Transport == TransportTypes.Http) { mcpServerBuilder.WithHttpTransport(); } @@ -186,6 +245,48 @@ public static IServiceCollection AddAzureMcpServer(this IServiceCollection servi return services; } + /// + /// Using configures . + /// + /// Service Collection to add configuration logic to. + public static void InitializeConfigurationAndOptions(this IServiceCollection services) + { + var environment = Environment.GetEnvironmentVariable("DOTNET_ENVIRONMENT") ?? "Production"; + var configuration = new ConfigurationBuilder() + .AddJsonFile("appsettings.json", optional: false) + .AddJsonFile($"appsettings.{environment}.json", optional: true) + .AddEnvironmentVariables() + .SetBasePath(AppContext.BaseDirectory) + .Build(); + services.AddSingleton(configuration); + + services.AddOptions() + .BindConfiguration(string.Empty) + .Configure>((options, rootConfiguration, serviceStartOptions) => + { + // This environment variable can be used to disable telemetry collection entirely. This takes precedence + // over any other settings. + var collectTelemetry = rootConfiguration.GetValue("AZURE_MCP_COLLECT_TELEMETRY", true); + var transport = serviceStartOptions.Value.Transport; + var isStdioTransport = string.IsNullOrEmpty(transport) + || string.Equals(transport, TransportTypes.StdIo, StringComparison.OrdinalIgnoreCase); + + // Assembly.GetEntryAssembly is used to retrieve the version of the server application as that is + // the assembly that will run the tool calls. + var entryAssembly = Assembly.GetEntryAssembly(); + if (entryAssembly == null) + { + throw new InvalidOperationException("Entry assembly must be a managed assembly."); + } + + options.Version = AssemblyHelper.GetAssemblyVersion(entryAssembly); + + // if transport is not set (default to stdio) or is set to stdio, enable telemetry + // telemetry is disabled for HTTP transport + options.IsTelemetryEnabled = collectTelemetry && isStdioTransport; + }); + } + /// /// Generates comprehensive instructions for using the Azure MCP Server effectively. /// Includes Azure best practices from embedded resource files. @@ -219,7 +320,7 @@ private static string GetServerInstructions() /// Combined content from all Azure best practices resource files. private static string LoadAzureRulesForBestPractices() { - var coreAssembly = typeof(AzureMcpServiceCollectionExtensions).Assembly; + var coreAssembly = typeof(ServiceCollectionExtensions).Assembly; var azureRulesContent = new StringBuilder(); // List of known best practices resource files diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceInfoCommand.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceInfoCommand.cs new file mode 100644 index 0000000000..ce269a0d08 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceInfoCommand.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Configuration; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Azure.Mcp.Core.Areas.Server.Commands; + +/// +/// Command that provides basic server information. +/// +[HiddenCommand] +public sealed class ServiceInfoCommand(IOptions serverOptions, ILogger logger) : BaseCommand +{ + private static readonly EmptyOptions EmptyOptions = new EmptyOptions(); + + private readonly IOptions _serverOptions = serverOptions; + private readonly ILogger _logger = logger; + + public override string Id => "add0f6fe-258c-45c4-af74-0c165d4913cb"; + + public override string Name => "info"; + + public override string Description => "Displays running MCP server information."; + + public override string Title => "Server information."; + + public override ToolMetadata Metadata => new ToolMetadata + { + Destructive = false, + Idempotent = true, + OpenWorld = false, + ReadOnly = true, + LocalRequired = false, + Secret = false + }; + + public override Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + try + { + context.Response.Results = ResponseResult.Create( + new ServiceInfoCommandResult( + _serverOptions.Value.Name, + _serverOptions.Value.Version), + ServiceInfoJsonContext.Default.ServiceInfoCommandResult); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error obtaining server information."); + HandleException(context, ex); + } + + return Task.FromResult(context.Response); + } + + protected override EmptyOptions BindOptions(ParseResult parseResult) + { + return EmptyOptions; + } + + internal record ServiceInfoCommandResult(string Name, string Version); +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceInfoJsonContext.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceInfoJsonContext.cs new file mode 100644 index 0000000000..f4b296fa66 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceInfoJsonContext.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Core.Areas.Server.Commands; + +[JsonSerializable(typeof(ServiceInfoCommand.ServiceInfoCommandResult))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +internal partial class ServiceInfoJsonContext : JsonSerializerContext +{ +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs index 9630bcdc62..17cf4423e8 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs @@ -2,18 +2,31 @@ // Licensed under the MIT License. using System.CommandLine.Parsing; +using System.Diagnostics; using System.Net; +using Azure.Mcp.Core.Areas.Server.Models; using Azure.Mcp.Core.Areas.Server.Options; using Azure.Mcp.Core.Commands; using Azure.Mcp.Core.Helpers; +using Azure.Mcp.Core.Services.Azure; +using Azure.Mcp.Core.Services.Azure.Authentication; +using Azure.Mcp.Core.Services.Caching; +using Azure.Mcp.Core.Services.Telemetry; +using Azure.Monitor.OpenTelemetry.Exporter; +using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Microsoft.Identity.Web; +using OpenTelemetry; using OpenTelemetry.Logs; using OpenTelemetry.Metrics; using OpenTelemetry.Trace; +using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants; namespace Azure.Mcp.Core.Areas.Server.Commands; @@ -48,6 +61,10 @@ public sealed class ServiceStartCommand : BaseCommand public static Action ConfigureServices { get; set; } = _ => { }; + public static Func InitializeServicesAsync { get; set; } = _ => Task.CompletedTask; + + public override string Id => "9953ff62-e3d7-4bdf-9b70-d569e54e3df1"; + /// /// Registers command options for the service start command. /// @@ -58,10 +75,24 @@ protected override void RegisterOptions(Command command) command.Options.Add(ServiceOptionDefinitions.Transport); command.Options.Add(ServiceOptionDefinitions.Namespace); command.Options.Add(ServiceOptionDefinitions.Mode); + command.Options.Add(ServiceOptionDefinitions.Tool); command.Options.Add(ServiceOptionDefinitions.ReadOnly); command.Options.Add(ServiceOptionDefinitions.Debug); - command.Options.Add(ServiceOptionDefinitions.EnableInsecureTransports); + command.Options.Add(ServiceOptionDefinitions.DangerouslyDisableHttpIncomingAuth); command.Options.Add(ServiceOptionDefinitions.InsecureDisableElicitation); + command.Options.Add(ServiceOptionDefinitions.OutgoingAuthStrategy); + command.Validators.Add(commandResult => + { + string transport = ResolveTransport(commandResult); + bool httpIncomingAuthDisabled = commandResult.GetValueOrDefault(ServiceOptionDefinitions.DangerouslyDisableHttpIncomingAuth); + ValidateMode(commandResult.GetValueOrDefault(ServiceOptionDefinitions.Mode), commandResult); + ValidateTransportConfiguration(transport, httpIncomingAuthDisabled, commandResult); + ValidateNamespaceAndToolMutualExclusion( + commandResult.GetValueOrDefault(ServiceOptionDefinitions.Namespace.Name), + commandResult.GetValueOrDefault(ServiceOptionDefinitions.Tool.Name), + commandResult); + ValidateOutgoingAuthStrategy(commandResult); + }); } /// @@ -71,53 +102,39 @@ protected override void RegisterOptions(Command command) /// A configured ServiceStartOptions instance. protected override ServiceStartOptions BindOptions(ParseResult parseResult) { + var mode = parseResult.GetValueOrDefault(ServiceOptionDefinitions.Mode.Name); + var tools = parseResult.GetValueOrDefault(ServiceOptionDefinitions.Tool.Name); + + // When --tool switch is used, automatically change the mode to "all" + if (tools != null && tools.Length > 0) + { + mode = ModeTypes.All; + } + + var outgoingAuthStrategy = ResolveAuthStrategy(parseResult); + var options = new ServiceStartOptions { - Transport = parseResult.GetValueOrDefault(ServiceOptionDefinitions.Transport.Name) ?? TransportTypes.StdIo, + Transport = ResolveTransport(parseResult), Namespace = parseResult.GetValueOrDefault(ServiceOptionDefinitions.Namespace.Name), - Mode = parseResult.GetValueOrDefault(ServiceOptionDefinitions.Mode.Name), + Mode = mode, + Tool = tools, ReadOnly = parseResult.GetValueOrDefault(ServiceOptionDefinitions.ReadOnly.Name), Debug = parseResult.GetValueOrDefault(ServiceOptionDefinitions.Debug.Name), - EnableInsecureTransports = parseResult.GetValueOrDefault(ServiceOptionDefinitions.EnableInsecureTransports.Name), - InsecureDisableElicitation = parseResult.GetValueOrDefault(ServiceOptionDefinitions.InsecureDisableElicitation.Name) + DangerouslyDisableHttpIncomingAuth = parseResult.GetValueOrDefault(ServiceOptionDefinitions.DangerouslyDisableHttpIncomingAuth.Name), + InsecureDisableElicitation = parseResult.GetValueOrDefault(ServiceOptionDefinitions.InsecureDisableElicitation.Name), + OutgoingAuthStrategy = outgoingAuthStrategy }; return options; } - /// - /// Validates the command options and arguments. - /// - /// The command result to validate. - /// Optional response object to set error details. - /// A ValidationResult indicating whether the validation passed. - public override ValidationResult Validate(CommandResult commandResult, CommandResponse? commandResponse) - { - // First run the base validation for required options and parser errors - var baseResult = base.Validate(commandResult, commandResponse); - if (!baseResult.IsValid) - { - return baseResult; - } - - // Get option values directly from commandResult - var mode = commandResult.GetValueOrDefault(ServiceOptionDefinitions.Mode); - var transport = commandResult.GetValueOrDefault(ServiceOptionDefinitions.Transport); - var enableInsecureTransports = commandResult.GetValueOrDefault(ServiceOptionDefinitions.EnableInsecureTransports); - - // Validate and return early on any failures - return ValidateMode(mode, commandResponse) ?? - ValidateTransport(transport, commandResponse) ?? - ValidateInsecureTransportsConfiguration(enableInsecureTransports, commandResponse) ?? - new ValidationResult { IsValid = true }; - } - /// /// Executes the service start command, creating and starting the MCP server. /// /// The command execution context. /// The parsed command options. /// A command response indicating the result of the operation. - public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) { if (!Validate(parseResult.CommandResult, context.Response).IsValid) { @@ -126,11 +143,24 @@ public override async Task ExecuteAsync(CommandContext context, var options = BindOptions(parseResult); + // Update the UserAgentPolicy for all Azure service calls to include the transport type. + var transport = string.IsNullOrEmpty(options.Transport) ? TransportTypes.StdIo : options.Transport; + BaseAzureService.InitializeUserAgentPolicy(transport); + try { + using var tracerProvider = AddIncomingAndOutgoingHttpSpans(options); + using var host = CreateHost(options); - await host.StartAsync(CancellationToken.None); - await host.WaitForShutdownAsync(CancellationToken.None); + + await InitializeServicesAsync(host.Services); + + await host.StartAsync(cancellationToken); + + var telemetryService = host.Services.GetRequiredService(); + LogStartTelemetry(telemetryService, options); + + await host.WaitForShutdownAsync(cancellationToken); return context.Response; } @@ -141,83 +171,120 @@ public override async Task ExecuteAsync(CommandContext context, } } + internal static void LogStartTelemetry(ITelemetryService telemetryService, ServiceStartOptions options) + { + using var activity = telemetryService.StartActivity(ActivityName.ServerStarted); + + if (activity != null) + { + activity.SetTag(TagName.Transport, options.Transport); + activity.SetTag(TagName.ServerMode, options.Mode); + activity.SetTag(TagName.IsReadOnly, options.ReadOnly); + activity.SetTag(TagName.InsecureDisableElicitation, options.InsecureDisableElicitation); + activity.SetTag(TagName.DangerouslyDisableHttpIncomingAuth, options.DangerouslyDisableHttpIncomingAuth); + activity.SetTag(TagName.IsDebug, options.Debug); + + if (options.Namespace != null && options.Namespace.Length > 0) + { + activity.SetTag(TagName.Namespace, string.Join(",", options.Namespace)); + } + if (options.Tool != null && options.Tool.Length > 0) + { + activity.SetTag(TagName.Tool, string.Join(",", options.Tool)); + } + } + } + /// /// Validates if the provided mode is a valid mode type. /// /// The mode to validate. - /// Optional command response to update on failure. - /// ValidationResult with error details if invalid, null if valid. - private static ValidationResult? ValidateMode(string? mode, CommandResponse? commandResponse) + /// Command result to update on failure. + private static void ValidateMode(string? mode, CommandResult commandResult) { if (mode == ModeTypes.SingleToolProxy || mode == ModeTypes.NamespaceProxy || - mode == ModeTypes.All) + mode == ModeTypes.All || + mode == ModeTypes.ConsolidatedProxy) { - return null; // Success + return; // Success } - var result = new ValidationResult - { - IsValid = false, - ErrorMessage = $"Invalid mode '{mode}'. Valid modes are: {ModeTypes.SingleToolProxy}, {ModeTypes.NamespaceProxy}, {ModeTypes.All}." - }; - - SetValidationError(commandResponse, result.ErrorMessage!, HttpStatusCode.BadRequest); - return result; + commandResult.AddError($"Invalid mode '{mode}'. Valid modes are: {ModeTypes.SingleToolProxy}, {ModeTypes.NamespaceProxy}, {ModeTypes.All}, {ModeTypes.ConsolidatedProxy}."); } /// - /// Validates if the provided transport is valid. + /// Validates the transport configuration, ensuring the transport type is valid and compatible with other options. + /// Verifies that HTTP transport is only used when available (ENABLE_HTTP), and that --dangerously-disable-http-incoming-auth + /// is only specified with HTTP transport. /// /// The transport to validate. - /// Optional command response to update on failure. - /// ValidationResult with error details if invalid, null if valid. - private static ValidationResult? ValidateTransport(string? transport, CommandResponse? commandResponse) + /// Whether HTTP incoming authentication is disabled. + /// Command result to update on failure. + private static void ValidateTransportConfiguration(string transport, bool httpIncomingAuthDisabled, CommandResult commandResult) { - if (transport is null || transport == TransportTypes.StdIo) + if (transport == TransportTypes.StdIo) { - return null; // Success + if (httpIncomingAuthDisabled) + { + commandResult.AddError($"The --dangerously-disable-http-incoming-auth option cannot be used with the {TransportTypes.StdIo} transport. To use this option, specify {TransportTypes.Http} transport with --transport http."); + } + return; } - var result = new ValidationResult + if (transport == TransportTypes.Http) { - IsValid = false, - ErrorMessage = $"Invalid transport '{transport}'. Valid transports are: {TransportTypes.StdIo}." - }; +#if ENABLE_HTTP + return; +#else + commandResult.AddError($"{TransportTypes.Http} transport is only supported in the Docker image distribution of Azure MCP Server. Please use the Docker image or switch to {TransportTypes.StdIo} transport."); + return; +#endif + } - SetValidationError(commandResponse, result.ErrorMessage!, HttpStatusCode.BadRequest); - return result; + commandResult.AddError($"Invalid transport '{transport}'. Valid transports are: {TransportTypes.StdIo}, {TransportTypes.Http}."); } /// - /// Validates if the insecure transport configuration is valid. + /// Validates that --namespace and --tool options are not used together. /// - /// Whether insecure transports are enabled. - /// Optional command response to update on failure. - /// ValidationResult with error details if invalid, null if valid. - private static ValidationResult? ValidateInsecureTransportsConfiguration(bool enableInsecureTransports, CommandResponse? commandResponse) + /// The namespace values. + /// The tool values. + /// Command result to update on failure. + private static void ValidateNamespaceAndToolMutualExclusion(string[]? namespaces, string[]? tools, CommandResult commandResult) { - // If insecure transports are not enabled, configuration is valid - if (!enableInsecureTransports) - { - return null; // Success - } + bool hasNamespace = namespaces != null && namespaces.Length > 0; + bool hasTool = tools != null && tools.Length > 0; - // If insecure transports are enabled, check if proper credentials are configured - var hasCredentials = EnvironmentHelpers.GetEnvironmentVariableAsBool("AZURE_MCP_INCLUDE_PRODUCTION_CREDENTIALS"); - if (hasCredentials) + if (hasNamespace && hasTool) { - return null; // Success + commandResult.AddError("The --namespace and --tool options cannot be used together. Please specify either --namespace to filter by service namespace or --tool to filter by specific tool names, but not both."); } + } - var result = new ValidationResult + /// + /// Validates that the outgoing authentication strategy is compatible with the hosting mode. + /// + /// Command result to update on failure. + private static void ValidateOutgoingAuthStrategy(CommandResult commandResult) + { + var outgoingAuthStrategy = commandResult.GetValueOrDefault(ServiceOptionDefinitions.OutgoingAuthStrategy.Name); + if (outgoingAuthStrategy == OutgoingAuthStrategy.UseOnBehalfOf) { - IsValid = false, - ErrorMessage = "Using --enable-insecure-transport requires the host to have either Managed Identity or Workload Identity enabled. Please refer to the troubleshooting guidelines here at https://aka.ms/azmcp/troubleshooting." - }; +#if ENABLE_HTTP + string transport = ResolveTransport(commandResult); + bool httpIncomingAuthDisabled = commandResult.GetValueOrDefault(ServiceOptionDefinitions.DangerouslyDisableHttpIncomingAuth); - SetValidationError(commandResponse, result.ErrorMessage!, HttpStatusCode.InternalServerError); - return result; + if (transport != TransportTypes.Http || httpIncomingAuthDisabled) + { + commandResult.AddError($"The {OutgoingAuthStrategy.UseOnBehalfOf} outgoing authentication strategy requires the server to run in authenticated HTTP mode (--transport http without --{ServiceOptionDefinitions.DangerouslyDisableHttpIncomingAuthName})."); + } + return; +#else + commandResult.AddError($"{OutgoingAuthStrategy.UseOnBehalfOf} outgoing authentication strategy is only supported in the Docker image distribution of Azure MCP Server. " + + "Please use the Docker image or switch to a different outgoing authentication strategy."); +#endif + } } /// @@ -231,8 +298,12 @@ ArgumentException argEx when argEx.Message.Contains("Invalid transport") => "Invalid transport option specified. Use --transport stdio for the supported transport mechanism.", ArgumentException argEx when argEx.Message.Contains("Invalid mode") => "Invalid mode option specified. Use --mode single, namespace, or all for the supported modes.", - InvalidOperationException invOpEx when invOpEx.Message.Contains("Using --enable-insecure-transport") => - "Insecure transport configuration error. Ensure proper authentication configured with Managed Identity or Workload Identity.", + ArgumentException argEx when argEx.Message.Contains("--namespace and --tool options cannot be used together") => + "Configuration error: The --namespace and --tool options are mutually exclusive. Use either one or the other to filter available tools.", + ArgumentException argEx when argEx.Message.Contains($"{OutgoingAuthStrategy.UseOnBehalfOf} outgoing authentication strategy") => + $"Configuration error: The {OutgoingAuthStrategy.UseOnBehalfOf} authentication strategy requires the server to run in authenticated HTTP mode (--transport http without --{ServiceOptionDefinitions.DangerouslyDisableHttpIncomingAuthName}).", + InvalidOperationException invOpEx when invOpEx.Message.Contains("Using --dangerously-disable-http-incoming-auth") => + "Configuration error to disable incoming HTTP authentication. Ensure proper authentication is configured with Managed Identity or Workload Identity.", _ => base.GetErrorMessage(ex) }; @@ -243,14 +314,25 @@ InvalidOperationException invOpEx when invOpEx.Message.Contains("Using --enable- /// An IHost instance configured for the MCP server. private IHost CreateHost(ServiceStartOptions serverOptions) { - if (serverOptions.EnableInsecureTransports) +#if ENABLE_HTTP + if (serverOptions.Transport == TransportTypes.Http) { - return CreateHttpHost(serverOptions); + if (serverOptions.DangerouslyDisableHttpIncomingAuth) + { + return CreateIncomingAuthDisabledHttpHost(serverOptions); + } + else + { + return CreateHttpHost(serverOptions); + } } else { return CreateStdioHost(serverOptions); } +#else + return CreateStdioHost(serverOptions); +#endif } /// @@ -288,6 +370,9 @@ private IHost CreateStdioHost(ServiceStartOptions serverOptions) }) .ConfigureServices(services => { + // Configure the outgoing authentication strategy. + services.AddSingleIdentityTokenCredentialProvider(); + ConfigureServices(services); ConfigureMcpServer(services, serverOptions); }) @@ -301,46 +386,230 @@ private IHost CreateStdioHost(ServiceStartOptions serverOptions) /// An IHost instance configured for HTTP transport. private IHost CreateHttpHost(ServiceStartOptions serverOptions) { - return Host.CreateDefaultBuilder() - .ConfigureLogging(logging => - { - logging.ClearProviders(); - logging.ConfigureOpenTelemetryLogger(); - logging.AddEventSourceLogger(); - logging.AddConsole(); - }) - .ConfigureWebHostDefaults(webBuilder => + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + + // Configure logging + builder.Logging.ClearProviders(); + builder.Logging.ConfigureOpenTelemetryLogger(); + builder.Logging.AddEventSourceLogger(); + builder.Logging.AddConsole(); + + IServiceCollection services = builder.Services; + + // Configure outgoing and incoming authentication and authorization. + // + // Configure incoming authentication and authorization. + MicrosoftIdentityWebApiAuthenticationBuilderWithConfiguration authBuilder = services + .AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddMicrosoftIdentityWebApi(builder.Configuration); + + // Configure incoming auth JWT Bearer events for OAuth protected resource metadata. + services.Configure(JwtBearerDefaults.AuthenticationScheme, options => + { + options.Events = new JwtBearerEvents { - webBuilder.ConfigureServices(services => + OnChallenge = context => { - services.AddCors(options => + // Add resource_metadata parameter to WWW-Authenticate header + if (!context.Response.HasStarted) { - options.AddPolicy("AllowAll", policy => - { - policy.AllowAnyOrigin() - .AllowAnyMethod() - .AllowAnyHeader(); - }); - }); + HttpRequest request = context.Request; + string resourceMetadataUrl = $"{request.Scheme}://{request.Host}/.well-known/oauth-protected-resource"; + + // Modify the WWW-Authenticate header to include resource_metadata + context.Response.Headers.WWWAuthenticate = + $"Bearer realm=\"{request.Host}\", resource_metadata=\"{resourceMetadataUrl}\""; + } + return Task.CompletedTask; + } + }; + }); + + // Configure authorization policy for MCP access. + services.AddAuthorizationBuilder() + .SetFallbackPolicy(null) + .AddPolicy("McpAccess", policy => + { + policy.RequireAuthenticatedUser(); + + // Naming conventions used based on well-known Microsoft services, like MS Graph: + // - Scopes for delegated permissions: Mcp.Tools.Verb + // - App roles for application permissions: Mcp.Tools.Verb.All + // As of Oct 2025, we only have ReadWrite as a verb, but this can be extended + // in the future as needed. Other scenarios that aren't "MCP" or "Tools" can + // also be added in the future as they become relevant. + policy.RequireScopeOrAppPermission( + allowedScopeValues: ["Mcp.Tools.ReadWrite"], + allowedAppPermissionValues: ["Mcp.Tools.ReadWrite.All"]); + }); + + // Configure outgoing authentication strategy + if (serverOptions.OutgoingAuthStrategy == OutgoingAuthStrategy.UseOnBehalfOf) + { + services.AddHttpOnBehalfOfTokenCredentialProvider(authBuilder); + } + else + { + services.AddSingleIdentityTokenCredentialProvider(); + } - ConfigureServices(services); - ConfigureMcpServer(services, serverOptions); - }); + // Add a multi-user, HTTP context-aware caching strategy to isolate cache entries. + services.AddHttpServiceCacheService(); - webBuilder.Configure(app => + + // Configure non-MCP controllers/endpoints/routes/etc. + services.AddHealthChecks(); + + // Configure CORS + // We're allowing all origins, methods, and headers to support any web + // browser clients. + // Non-browser clients are unaffected by CORS. + services.AddCors(options => + { + options.AddPolicy("AllowAll", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); + }); + + // Configure services + ConfigureServices(services); // Our static callback hook + ConfigureMcpServer(services, serverOptions); + + WebApplication app = builder.Build(); + + UseHttpsRedirectionIfEnabled(app); + + // Configure middleware pipeline + app.UseCors("AllowAll"); + app.UseRouting(); + + // Add OAuth protected resource metadata middleware + // + app.Use(async (context, next) => + { + if (context.Request.Path == "/.well-known/oauth-protected-resource" && + context.Request.Method == "GET") + { + IOptionsMonitor azureAdOptionsMonitor = context + .RequestServices + .GetRequiredService>(); + MicrosoftIdentityOptions azureAdOptions = azureAdOptionsMonitor.Get(JwtBearerDefaults.AuthenticationScheme); + HttpRequest request = context.Request; + string baseUrl = $"{request.Scheme}://{request.Host}"; + string? clientId = azureAdOptions.ClientId; + string? tenantId = azureAdOptions.TenantId; + string instance = azureAdOptions.Instance?.TrimEnd('/') ?? "https://login.microsoftonline.com"; + + var metadata = new OAuthProtectedResourceMetadata { - app.UseCors("AllowAll"); - app.UseRouting(); - app.UseEndpoints(endpoints => - { - endpoints.MapMcp(); - }); - }); + Resource = baseUrl, + AuthorizationServers = [$"{instance}/{tenantId}/v2.0"], + + // Only delegated permissions for user principal authorization is listed here. + // Client with users send these scopes to the identity platform to acquire an + // access token. + // However, special to Entra, service principals are expected to always + // request the special `app-id/.default` scope because service principals use + // app roles/app permissions instead of scopes. At time of writing (Oct 2025), + // we don't solve this problem here. Instead we expect any service principal + // clients to be hardcoded to use the `app-id/.default` scope when requesting + // access tokens for our endpoint and for the owners of the client and MCP + // server's service principals to ensure the necessary app roles are assigned + // upfront. + ScopesSupported = [$"{clientId}/Mcp.Tools.ReadWrite"], + BearerMethodsSupported = ["header"], + + // Intentionally pointing to MCP repo for documentation. Could eventually + // have a dedicated usage doc page, potentially provided by this service itself. + ResourceDocumentation = "https://github.com/Microsoft/mcp" + }; + + context.Response.ContentType = "application/json"; + await JsonSerializer.SerializeAsync( + context.Response.Body, + metadata, + OAuthMetadataJsonContext.Default.OAuthProtectedResourceMetadata); + return; + } + + await next(context); + }); + + // AuthN/Z are always required in the remote HTTP service scenario. + app.UseAuthentication(); + app.UseAuthorization(); + + IEndpointConventionBuilder mcpEndpointBuilder = app.MapMcp(); + // All MCP endpoints require MCP.All scope or role + mcpEndpointBuilder.RequireAuthorization("McpAccess"); + + // Map non-MCP endpoints. + // Health checks are anonymous (no authentication required) + app.MapHealthChecks("/health") + .AllowAnonymous(); + + return app; + } - var url = GetSafeAspNetCoreUrl(); - webBuilder.UseUrls(url); - }) - .Build(); + /// + /// Creates a host for HTTP transport without incoming authentication. + /// + /// The server configuration options. + /// An IHost instance configured for HTTP transport. + private IHost CreateIncomingAuthDisabledHttpHost(ServiceStartOptions serverOptions) + { + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + + InitializeListingUrls(builder, serverOptions); + + // Configure logging + builder.Logging.ClearProviders(); + builder.Logging.ConfigureOpenTelemetryLogger(); + builder.Logging.AddEventSourceLogger(); + builder.Logging.AddConsole(); + + IServiceCollection services = builder.Services; + + // Configure single identity token credential provider for outgoing authentication + services.AddSingleIdentityTokenCredentialProvider(); + + // Configure CORS + // We're allowing all origins, methods, and headers to support any web + // browser clients. + // Non-browser clients are unaffected by CORS. + services.AddCors(options => + { + options.AddPolicy("AllowAll", policy => + { + policy.AllowAnyOrigin() + .AllowAnyMethod() + .AllowAnyHeader(); + }); + }); + + // Configure services + ConfigureServices(services); // Our static callback hook + ConfigureMcpServer(services, serverOptions); + + // We still use the multi-user, HTTP context-aware caching strategy here + // because we don't yet know what security model we want for this "insecure" mode. + // As a positive, it gives some isolation locally, but that's not a + // design strategy we've fully vetted or endorsed. + services.AddHttpServiceCacheService(); + + WebApplication app = builder.Build(); + + UseHttpsRedirectionIfEnabled(app); + + // Configure middleware pipeline + app.UseCors("AllowAll"); + app.UseRouting(); + app.MapMcp(); + + return app; } /// @@ -354,11 +623,17 @@ private static void ConfigureMcpServer(IServiceCollection services, ServiceStart } /// - /// Gets a safe ASP.NET Core URL with security validation. + /// Initializes the URL for ASP.NET Core to bind to. /// - /// A validated URL string for ASP.NET Core binding. - private static string GetSafeAspNetCoreUrl() + private static void InitializeListingUrls(WebApplicationBuilder builder, ServiceStartOptions options) { + if (!options.DangerouslyDisableHttpIncomingAuth) + { + // When running in secured HTTP mode, allow the standard IConfiguration binding to handle + // the ASPNETCORE_URLS value without any additional validation. + return; + } + string url = Environment.GetEnvironmentVariable("ASPNETCORE_URLS") ?? "http://127.0.0.1:5001"; if (url.Contains(';')) @@ -395,6 +670,161 @@ private static string GetSafeAspNetCoreUrl() $"Set ALLOW_INSECURE_EXTERNAL_BINDING=true if you intentionally want to bind beyond loopback."); } - return url; + builder.WebHost.UseUrls(url); + } + + /// + /// Resolves the service mode and outgoing authentication strategy based on parsed command line options, applying appropriate defaults. + /// + /// The parsed command line arguments. + /// A tuple containing whether to run as remote HTTP service and the outgoing auth strategy. + private static OutgoingAuthStrategy ResolveAuthStrategy(ParseResult parseResult) + { +#if ENABLE_HTTP + var outgoingAuthStrategy = parseResult.GetValueOrDefault(ServiceOptionDefinitions.OutgoingAuthStrategy.Name); + if (outgoingAuthStrategy == OutgoingAuthStrategy.NotSet) + { + string transport = ResolveTransport(parseResult); + if (transport == TransportTypes.Http) + { + bool httpIncomingAuthDisabled = parseResult.GetValueOrDefault(ServiceOptionDefinitions.DangerouslyDisableHttpIncomingAuth.Name); + return httpIncomingAuthDisabled + ? OutgoingAuthStrategy.UseHostingEnvironmentIdentity + : OutgoingAuthStrategy.UseOnBehalfOf; + } + else + { + return OutgoingAuthStrategy.UseHostingEnvironmentIdentity; + } + } + return outgoingAuthStrategy; +#else + return OutgoingAuthStrategy.UseHostingEnvironmentIdentity; +#endif + } + + /// + /// Resolves the transport type from parsed command line arguments, defaulting to STDIO if not specified. + /// + /// The parsed command line arguments. + /// The transport type string (stdio or http). + private static string ResolveTransport(ParseResult parseResult) + { + return parseResult.GetValueOrDefault(ServiceOptionDefinitions.Transport.Name) ?? TransportTypes.StdIo; + } + + /// + /// Resolves the transport type from command result, defaulting to STDIO if not specified. + /// + /// The command result to extract transport from. + /// The transport type string (stdio or http). + private static string ResolveTransport(CommandResult commandResult) + { + return commandResult.GetValueOrDefault(ServiceOptionDefinitions.Transport.Name) ?? TransportTypes.StdIo; + } + + private static WebApplication UseHttpsRedirectionIfEnabled(WebApplication app) + { + // Some hosting environments may not need HTTPS redirection, such as: + // - Running behind a reverse proxy that handles TLS termination. + // - Local development when not using self-signed development certs. + // - The application or server's HTTP stack is not listening for non-HTTPS requests. + // + // Safe default to enable HTTPS redirection unless explicitly opted-out. + string? httpsRedirectionOptOut = Environment.GetEnvironmentVariable("AZURE_MCP_DANGEROUSLY_DISABLE_HTTPS_REDIRECTION"); + if (!bool.TryParse(httpsRedirectionOptOut, out bool isOptedOut) || !isOptedOut) + { + app.UseHttpsRedirection(); + } + + return app; + } + + /// + /// Configures incoming and outgoing HTTP spans for self-hosted HTTP mode with Azure Monitor exporter. + /// + /// The server configuration options. + /// + /// A instance if telemetry is enabled and properly configured for HTTP transport; + /// otherwise, null. + /// + /// + /// Telemetry is only configured when: + /// + /// The transport is HTTP (not STDIO) + /// AZURE_MCP_COLLECT_TELEMETRY is not explicitly set to false + /// APPLICATIONINSIGHTS_CONNECTION_STRING environment variable is set + /// + /// The tracer provider includes ASP.NET Core and HttpClient instrumentation with filtering + /// to avoid duplicate spans and telemetry loops. + /// This telemetry configuration is intended for self-hosted scenarios where + /// the MCP server is running in HTTP mode. This creates an independent telemetry pipeline using TracerProvider to export + /// traces to user-configured Application Insights instance only when the necessary environment variables are set. This also honors + /// the AZURE_MCP_COLLECT_TELEMETRY environment variable to allow users to disable telemetry collection if desired. Note that this is + /// in addition to the telemetry configured in . + /// + private static TracerProvider? AddIncomingAndOutgoingHttpSpans(ServiceStartOptions options) + { + if (options.Transport != TransportTypes.Http) + { + return null; + } + + string? collectTelemetry = Environment.GetEnvironmentVariable("AZURE_MCP_COLLECT_TELEMETRY"); + bool isTelemetryEnabled = string.IsNullOrWhiteSpace(collectTelemetry) || + (bool.TryParse(collectTelemetry, out bool shouldCollectTelemetry) && shouldCollectTelemetry); + + string? connectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"); + if (!isTelemetryEnabled || string.IsNullOrWhiteSpace(connectionString)) + { + return null; + } + + return Sdk.CreateTracerProviderBuilder() + // captures incoming HTTP requests + .AddAspNetCoreInstrumentation() + // captures outgoing HTTP requests with filtering + .AddHttpClientInstrumentation(o => o.FilterHttpRequestMessage = ShouldInstrumentHttpRequest) + .AddAzureMonitorTraceExporter(exporterOptions => exporterOptions.ConnectionString = connectionString) + .Build(); + } + + /// + /// Determines whether an HTTP request should be instrumented for telemetry collection. + /// + /// The HTTP request message to evaluate. + /// + /// true if the request should be instrumented; otherwise, false. + /// + /// + /// This method filters out specific requests to prevent telemetry issues: + /// + /// Application Insights ingestion endpoints (to avoid telemetry loops) + /// Requests where the parent span is from Azure SDK (to avoid duplicate spans) + /// + /// + private static bool ShouldInstrumentHttpRequest(HttpRequestMessage request) + { + // Exclude Application Insights ingestion requests to skip requests that are made to AppInsights when sending telemetry. + // See related issue - https://github.com/Azure/azure-sdk-for-net/issues/45366#issuecomment-2278511391 + if (request.RequestUri?.AbsoluteUri.Contains("applicationinsights.azure.com/v2.1/track", StringComparison.Ordinal) == true) + { + return false; + } + + // **NOTE**: This check is copied from the UseAzureMonitor extension method in the Azure SDK repository: + // https://github.com/Azure/azure-sdk-for-net/blob/242ba3eca16d914522669ae62baac7437bf71db8/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/src/OpenTelemetryBuilderExtensions.cs#L98-L108 + // The decision to filter these out is not finalized for the product. We may revisit this in the future depending on + // how users want to see telemetry from Azure SDK calls made by the MCP server. + + // Azure SDKs create their own client span before calling the service using HttpClient. + // To prevent duplicate spans (Azure SDK + HttpClient), filter HttpClient spans when + // the parent span is from Azure SDK, as it contains all relevant information. + Activity? parentActivity = Activity.Current?.Parent; + if (parentActivity?.Source.Name == "Azure.Core.Http") + { + return false; + } + return true; } } diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/BaseToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/BaseToolLoader.cs index 61ebc74e71..a929df93d8 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/BaseToolLoader.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/BaseToolLoader.cs @@ -1,7 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Text.Json; +using Azure.Mcp.Core.Models.Elicitation; using Microsoft.Extensions.Logging; +using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -21,7 +24,15 @@ public abstract class BaseToolLoader(ILogger logger) : IToolLoader /// /// Cached empty JSON object to avoid repeated parsing. /// - protected static readonly JsonElement EmptyJsonObject = JsonDocument.Parse("{}").RootElement; + protected static readonly JsonElement EmptyJsonObject; + + static BaseToolLoader() + { + using (var jsonDoc = JsonDocument.Parse("{}")) + { + EmptyJsonObject = jsonDoc.RootElement.Clone(); + } + } private bool _disposed = false; @@ -111,45 +122,110 @@ protected virtual ValueTask DisposeAsyncCore() return ValueTask.CompletedTask; } - protected McpClientOptions CreateClientOptions(IMcpServer server) + protected McpClientOptions CreateClientOptions(McpServer server) { - SamplingCapability? samplingCapability = null; - ElicitationCapability? elicitationCapability = null; + McpClientHandlers handlers = new(); if (server.ClientCapabilities?.Sampling != null) { - samplingCapability = new SamplingCapability + handlers.SamplingHandler = (request, progress, token) => { - SamplingHandler = (request, progress, token) => - { - ArgumentNullException.ThrowIfNull(request); - return server.SampleAsync(request, token); - } + ArgumentNullException.ThrowIfNull(request); + return server.SampleAsync(request, token); }; } if (server.ClientCapabilities?.Elicitation != null) { - elicitationCapability = new ElicitationCapability + handlers.ElicitationHandler = (request, token) => { - ElicitationHandler = (request, token) => - { - ArgumentNullException.ThrowIfNull(request); - return server.ElicitAsync(request, token); - } + ArgumentNullException.ThrowIfNull(request); + return server.ElicitAsync(request, token); }; } var clientOptions = new McpClientOptions { ClientInfo = server.ClientInfo, - Capabilities = new ClientCapabilities - { - Sampling = samplingCapability, - Elicitation = elicitationCapability, - } + Handlers = handlers }; return clientOptions; } + + /// + /// Handles elicitation for commands that access sensitive data. + /// If elicitation is disabled or not supported, returns appropriate error result. + /// + /// The request context containing the MCP server. + /// The name of the tool being invoked. + /// Whether elicitation has been disabled via insecure option. + /// Logger instance for recording elicitation events. + /// Cancellation token for the operation. + /// + /// Null if elicitation was accepted or bypassed (operation should proceed). + /// A CallToolResult with IsError=true if elicitation was rejected or failed (operation should not proceed). + /// + protected static async Task HandleSecretElicitationAsync( + RequestContext request, + string toolName, + bool insecureDisableElicitation, + ILogger logger, + CancellationToken cancellationToken) + { + // Check if elicitation is disabled by insecure option + if (insecureDisableElicitation) + { + logger.LogWarning("Tool '{Tool}' handles sensitive data but elicitation is disabled via --insecure-disable-elicitation. Proceeding without user consent (INSECURE).", toolName); + return null; + } + + // If client doesn't support elicitation, treat as rejected and don't execute + if (!request.Server.SupportsElicitation()) + { + logger.LogWarning("Tool '{Tool}' handles sensitive data but client does not support elicitation. Operation rejected.", toolName); + return new CallToolResult + { + Content = [new TextContentBlock { Text = "This tool handles sensitive data and requires user consent, but the client does not support elicitation. Operation rejected for security." }], + IsError = true + }; + } + + try + { + logger.LogInformation("Tool '{Tool}' handles sensitive data. Requesting user confirmation via elicitation.", toolName); + + // Create the elicitation request using our custom model + var elicitationRequest = new ElicitationRequestParams + { + Message = $"⚠️ SECURITY WARNING: The tool '{toolName}' may expose secrets or sensitive information.\n\nThis operation could reveal confidential data such as passwords, API keys, certificates, or other sensitive values.\n\nDo you want to continue with this potentially sensitive operation?" + }; + + // Use our extension method to handle the elicitation + var elicitationResponse = await request.Server.RequestElicitationAsync(elicitationRequest, cancellationToken); + + if (elicitationResponse.Action != ElicitationAction.Accept) + { + logger.LogInformation("User {Action} the elicitation for tool '{Tool}'. Operation not executed.", + elicitationResponse.Action.ToString().ToLower(), toolName); + return new CallToolResult + { + Content = [new TextContentBlock { Text = $"Operation cancelled by user ({elicitationResponse.Action.ToString().ToLower()})." }], + IsError = true + }; + } + + logger.LogInformation("User accepted elicitation for tool '{Tool}'. Proceeding with execution.", toolName); + return null; + } + catch (Exception ex) + { + logger.LogError(ex, "Error during elicitation for tool '{Tool}': {Error}", toolName, ex.Message); + return new CallToolResult + { + Content = [new TextContentBlock { Text = $"Elicitation failed for sensitive tool '{toolName}': {ex.Message}. Operation not executed for security." }], + IsError = true + }; + } + } } diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs index ceff9bff3c..ff5b503f6d 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs @@ -8,10 +8,10 @@ using Azure.Mcp.Core.Commands; using Azure.Mcp.Core.Helpers; using Azure.Mcp.Core.Models.Elicitation; -using Azure.Mcp.Core.Services.Telemetry; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ModelContextProtocol.Protocol; +using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants; namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; @@ -23,15 +23,15 @@ public sealed class CommandFactoryToolLoader( IServiceProvider serviceProvider, CommandFactory commandFactory, IOptions options, - ILogger logger) : IToolLoader + ILogger logger) : BaseToolLoader(logger) { private readonly IServiceProvider _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + private readonly CommandFactory _commandFactory = commandFactory; private readonly IOptions _options = options; private IReadOnlyDictionary _toolCommands = (options.Value.Namespace == null || options.Value.Namespace.Length == 0) ? commandFactory.AllCommands : commandFactory.GroupCommands(options.Value.Namespace); - private readonly ILogger _logger = logger; public const string RawMcpToolInputOptionName = "raw-mcp-tool-input"; @@ -59,9 +59,21 @@ private static bool IsRawMcpToolInputOption(Option option) /// The request context containing parameters and metadata. /// A cancellation token. /// A result containing the list of available tools. - public ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken) + public override ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken) { - var tools = CommandFactory.GetVisibleCommands(_toolCommands) + var visibleCommands = CommandFactory.GetVisibleCommands(_toolCommands); + + // Filter by specific tools if provided + if (_options.Value.Tool != null && _options.Value.Tool.Length > 0) + { + visibleCommands = visibleCommands.Where(kvp => + { + var toolKey = kvp.Key; + return _options.Value.Tool.Any(tool => tool.Contains(toolKey, StringComparison.OrdinalIgnoreCase)); + }); + } + + var tools = visibleCommands .Select(kvp => GetTool(kvp.Key, kvp.Value)) .Where(tool => !_options.Value.ReadOnly || (tool.Annotations?.ReadOnlyHint == true)) .ToList(); @@ -79,7 +91,7 @@ public ValueTask ListToolsHandler(RequestContextThe request context containing parameters and metadata. /// A cancellation token. /// The result of the tool call operation. - public async ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken) + public override async ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken) { if (request.Params == null) { @@ -96,6 +108,32 @@ public async ValueTask CallToolHandler(RequestContext 0) + { + if (!_options.Value.Tool.Any(tool => tool.Contains(toolName, StringComparison.OrdinalIgnoreCase))) + { + var content = new TextContentBlock + { + Text = $"Tool '{toolName}' is not available. This server is configured to only expose the tools: {string.Join(", ", _options.Value.Tool.Select(t => $"'{t}'"))}", + }; + + return new CallToolResult + { + Content = [content], + IsError = true, + }; + } + } + + var activity = Activity.Current; + + if (activity != null) + { + activity.SetTag(TagName.ToolName, toolName); + } + var command = _toolCommands.GetValueOrDefault(toolName); if (command == null) { @@ -110,66 +148,23 @@ public async ValueTask CallToolHandler(RequestContext CallToolHandler(RequestContext= HttpStatusCode.Ambiguous; @@ -290,9 +285,9 @@ private static Tool GetTool(string fullName, IBaseCommand command) /// Disposes resources owned by this tool loader. /// CommandFactoryToolLoader doesn't own external resources that need disposal. /// - public async ValueTask DisposeAsync() + protected override ValueTask DisposeAsyncCore() { // CommandFactoryToolLoader doesn't create or manage disposable resources - await ValueTask.CompletedTask; + return ValueTask.CompletedTask; } } diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/CompositeToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/CompositeToolLoader.cs index 7e2f4bebe9..9659295a12 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/CompositeToolLoader.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/CompositeToolLoader.cs @@ -142,7 +142,7 @@ public override async ValueTask CallToolHandler(RequestContextThe server context for creating list tools requests. /// A token to monitor for cancellation requests. /// A task representing the asynchronous operation. - private async Task InitializeAsync(IMcpServer server, CancellationToken cancellationToken) + private async Task InitializeAsync(McpServer server, CancellationToken cancellationToken) { if (_isInitialized) { @@ -162,7 +162,7 @@ private async Task InitializeAsync(IMcpServer server, CancellationToken cancella var allTools = new List(); // Create a request for listing tools to populate the tool loader map - var listToolsRequest = new RequestContext(server) + var listToolsRequest = new RequestContext(server, new() { Method = RequestMethods.ToolsList }) { Params = new ListToolsRequestParams() }; diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs new file mode 100644 index 0000000000..44f206643b --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs @@ -0,0 +1,717 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Net; +using System.Text.Json.Nodes; +using Azure.Mcp.Core.Areas.Server.Commands.Discovery; +using Azure.Mcp.Core.Areas.Server.Models; +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Helpers; +using Azure.Mcp.Core.Models.Elicitation; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ModelContextProtocol; +using ModelContextProtocol.Protocol; +using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants; + +namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; + +/// +/// A tool loader that exposes Azure command groups as hierarchical namespace tools with direct in-process execution. +/// Provides the same functionality as but without spawning child azmcp processes. +/// Supports learn functionality for progressive discovery of commands within each namespace. +/// +public sealed class NamespaceToolLoader( + CommandFactory commandFactory, + IOptions options, + IServiceProvider serviceProvider, + ILogger logger, + bool applyFilter = true) : BaseToolLoader(logger) +{ + private readonly CommandFactory _commandFactory = commandFactory ?? throw new ArgumentNullException(nameof(commandFactory)); + private readonly IOptions _options = options ?? throw new ArgumentNullException(nameof(options)); + private readonly IServiceProvider _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + private readonly bool _applyFilter = applyFilter; + + private readonly Lazy> _availableNamespaces = new(() => + { + IEnumerable allSubGroups = commandFactory.RootGroup.SubGroup; + + if (applyFilter) + { + allSubGroups = allSubGroups + .Where(group => !DiscoveryConstants.IgnoredCommandGroups.Contains(group.Name, StringComparer.OrdinalIgnoreCase)) + .Where(group => options.Value.Namespace == null || + options.Value.Namespace.Length == 0 || + options.Value.Namespace.Contains(group.Name, StringComparer.OrdinalIgnoreCase)); + } + + return allSubGroups.Select(group => group.Name).ToList(); + }); + + private readonly Dictionary> _cachedToolLists = new(StringComparer.OrdinalIgnoreCase); + private ListToolsResult? _cachedListToolsResult; + + private const string ToolCallProxySchema = """ + { + "type": "object", + "properties": { + "tool": { + "type": "string", + "description": "The name of the tool to call." + }, + "parameters": { + "type": "object", + "description": "A key/value pair of parameters names and values to pass to the tool call command." + } + }, + "additionalProperties": false + } + """; + + private static readonly JsonElement ToolSchema = JsonSerializer.Deserialize(""" + { + "type": "object", + "properties": { + "intent": { + "type": "string", + "description": "The intent of the azure operation to perform." + }, + "command": { + "type": "string", + "description": "The command to execute against the specified tool." + }, + "parameters": { + "type": "object", + "description": "The parameters to pass to the tool command." + }, + "learn": { + "type": "boolean", + "description": "To learn about the tool and its supported child tools and parameters.", + "default": false + } + }, + "required": ["intent"], + "additionalProperties": false + } + """, ServerJsonContext.Default.JsonElement); + + public override ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken) + { + if (_cachedListToolsResult != null) + { + return ValueTask.FromResult(_cachedListToolsResult); + } + + var namespaces = _availableNamespaces.Value; + var allToolsResponse = new ListToolsResult + { + Tools = new List() + }; + + foreach (var namespaceName in namespaces) + { + var group = _commandFactory.RootGroup.SubGroup + .First(g => string.Equals(g.Name, namespaceName, StringComparison.OrdinalIgnoreCase)); + + var tool = new Tool + { + Name = namespaceName, + Description = group.Description + """ + This tool is a hierarchical MCP command router. + Sub commands are routed to MCP servers that require specific fields inside the "parameters" object. + To invoke a command, set "command" and wrap its args in "parameters". + Set "learn=true" to discover available sub commands. + """, + InputSchema = ToolSchema, + Annotations = new ToolAnnotations() + { + Title = group.Title ?? namespaceName, + DestructiveHint = group.ToolMetadata?.Destructive, + IdempotentHint = group.ToolMetadata?.Idempotent, + OpenWorldHint = group.ToolMetadata?.OpenWorld, + ReadOnlyHint = group.ToolMetadata?.ReadOnly, + }, + }; + + allToolsResponse.Tools.Add(tool); + } + + // Cache the result + _cachedListToolsResult = allToolsResponse; + return ValueTask.FromResult(allToolsResponse); + } + + public override async ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(request.Params?.Name)) + { + throw new ArgumentNullException(nameof(request.Params.Name), "Tool name cannot be null or empty."); + } + + string tool = request.Params.Name; + var args = request.Params?.Arguments; + string? intent = null; + string? command = null; + bool learn = false; + + // In namespace mode, the name of the tool is also its IAreaSetup name. + Activity.Current?.SetTag(TagName.ToolArea, tool); + + if (args != null) + { + if (args.TryGetValue("intent", out var intentElem) && intentElem.ValueKind == JsonValueKind.String) + { + intent = intentElem.GetString(); + } + if (args.TryGetValue("learn", out var learnElem) && learnElem.ValueKind == JsonValueKind.True) + { + learn = true; + } + if (args.TryGetValue("command", out var commandElem) && commandElem.ValueKind == JsonValueKind.String) + { + command = commandElem.GetString(); + } + } + + if (!learn && !string.IsNullOrEmpty(intent) && string.IsNullOrEmpty(command)) + { + learn = true; + } + + try + { + var activity = Activity.Current; + + if (learn) + { + return await InvokeToolLearn(request, intent ?? "", tool, cancellationToken); + } + else if (!string.IsNullOrEmpty(tool) && !string.IsNullOrEmpty(command)) + { + // We no longer spawn new processes to handle child tool invocations. + // So, we have to update ToolName to represent the namespace's child tool name + // rather than what is exposed to the user. The following inputs would + // be routed here. In both examples, the end-user's MCP client sees that we expose + // a tool called "storage" and would invoke our "storage" tool. + // + // A) { + // "intent": "List storage blobs.", + // "command": "blob_list", + // "parameters": [ "--name", "foo", "--subscription-id", "bar" ] + // } + // + // This is the case where the LLM knows what tool should be executed, so it passes + // in all the parameters required to execute the underlying Storage tool. + // + // B) { + // "intent": "List storage blobs.", + // "command": "blob_list", + // "parameters": [] + // "learn": true + // } + // + // This command attempts to learn what the command "blob_list" entails by + // invoking it with no parameters and "learn" == "true". The command will + // generally fail, providing the LLM with extra information it needs to pass + // in for the command to succeed the next time. + activity?.SetTag(TagName.ToolName, command); + + var toolParams = GetParametersFromArgs(args); + return await InvokeChildToolAsync(request, intent ?? "", tool, command, toolParams, cancellationToken); + } + } + catch (KeyNotFoundException ex) + { + _logger.LogError(ex, "Key not found while calling tool: {Tool}", tool); + + return new CallToolResult + { + Content = + [ + new TextContentBlock { + Text = $""" + The tool '{tool}.{command}' was not found or does not support the specified command. + Please ensure the tool name and command are correct. + If you want to learn about available tools, run again with the "learn=true" argument. + """ + } + ], + IsError = true + }; + } + + return new CallToolResult + { + Content = + [ + new TextContentBlock { + Text = """ + The "command" parameters are required when not learning + Run again with the "learn" argument to get a list of available tools and their parameters. + To learn about a specific tool, use the "tool" argument with the name of the tool. + """ + } + ], + IsError = false + }; + } + + private async Task InvokeChildToolAsync( + RequestContext request, + string? intent, + string namespaceName, + string command, + IReadOnlyDictionary parameters, + CancellationToken cancellationToken) + { + if (request.Params == null) + { + var content = new TextContentBlock + { + Text = "Cannot call tools with null parameters.", + }; + + _logger.LogWarning(content.Text); + + return new CallToolResult + { + Content = [content], + IsError = true, + }; + } + + Activity.Current?.SetTag(TagName.IsServerCommandInvoked, true); + IReadOnlyDictionary namespaceCommands; + try + { + namespaceCommands = _commandFactory.GroupCommands([namespaceName]); + if (namespaceCommands == null || namespaceCommands.Count == 0) + { + _logger.LogError("Failed to get commands for namespace: {Namespace}", namespaceName); + return await InvokeToolLearn(request, intent, namespaceName, cancellationToken); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception thrown while getting commands for namespace: {Namespace}", namespaceName); + return await InvokeToolLearn(request, intent, namespaceName, cancellationToken); + } + + try + { + var availableTools = GetChildToolList(request, namespaceName); + + // When the specified command is not available, we try to learn about the tool's capabilities + // and infer the command and parameters from the users intent. + if (!availableTools.Any(t => string.Equals(t.Name, command, StringComparison.OrdinalIgnoreCase))) + { + _logger.LogWarning("Namespace {Namespace} does not have a command {Command}.", namespaceName, command); + if (string.IsNullOrWhiteSpace(intent)) + { + return await InvokeToolLearn(request, intent, namespaceName, cancellationToken); + } + + var samplingResult = await GetCommandAndParametersFromIntentAsync(request, intent, namespaceName, availableTools, cancellationToken); + if (string.IsNullOrWhiteSpace(samplingResult.commandName)) + { + return await InvokeToolLearn(request, intent ?? "", namespaceName, cancellationToken); + } + + command = samplingResult.commandName; + parameters = samplingResult.parameters; + } + + await NotifyProgressAsync(request, $"Calling {namespaceName} {command}...", cancellationToken); + + if (!namespaceCommands.TryGetValue(command, out var cmd)) + { + _logger.LogError("Command {Command} found in tools but missing from namespace {Namespace} commands.", command, namespaceName); + return await InvokeToolLearn(request, intent, namespaceName, cancellationToken); + } + + // Check if this tool requires elicitation for sensitive data + var metadata = cmd.Metadata; + if (metadata.Secret) + { + var elicitationResult = await HandleSecretElicitationAsync( + request, + $"{namespaceName} {command}", + _options.Value.InsecureDisableElicitation, + _logger, + cancellationToken); + + if (elicitationResult != null) + { + return elicitationResult; + } + } + + var currentActivity = Activity.Current; + var commandContext = new CommandContext(_serviceProvider, currentActivity); + var realCommand = cmd.GetCommand(); + + ParseResult commandOptions; + if (realCommand.Options.Count == 1 && IsRawMcpToolInputOption(realCommand.Options[0])) + { + commandOptions = realCommand.ParseFromRawMcpToolInput(parameters); + } + else + { + commandOptions = realCommand.ParseFromDictionary(parameters); + } + + _logger.LogTrace("Executing namespace command '{Namespace} {Command}'", namespaceName, command); + + // It is possible that the command provided by the LLM is not one that exists, such as "blob-list". + // The logic above performs sampling to try and get a correct command name. "blob_get" in + // this case, which will be executed. + currentActivity?.SetTag(TagName.ToolName, command).SetTag(TagName.ToolId, cmd.Id); + + var commandResponse = await cmd.ExecuteAsync(commandContext, commandOptions, cancellationToken); + var jsonResponse = JsonSerializer.Serialize(commandResponse, ModelsJsonContext.Default.CommandResponse); + var isError = commandResponse.Status < HttpStatusCode.OK || commandResponse.Status >= HttpStatusCode.Ambiguous; + + if (jsonResponse.Contains("Missing required options", StringComparison.OrdinalIgnoreCase)) + { + var childToolSpecJson = GetChildToolJson(request, namespaceName, command); + + _logger.LogWarning("Namespace {Namespace} command {Command} requires additional parameters.", namespaceName, command); + var finalResponse = new CallToolResult + { + Content = + [ + new TextContentBlock { + Text = $""" + The '{command}' command is missing required parameters. + + - Review the following command spec and identify the required arguments from the input schema. + - Omit any arguments that are not required or do not apply to your use case. + - Wrap all command arguments into the root "parameters" argument. + - If required data is missing infer the data from your context or prompt the user as needed. + - Run the tool again with the "command" and root "parameters" object. + + Command Spec: + {childToolSpecJson} + """ + } + ], + IsError = true + }; + + // Add original response content + finalResponse.Content.Add(new TextContentBlock { Text = jsonResponse }); + return finalResponse; + } + + return new CallToolResult + { + Content = [new TextContentBlock { Text = jsonResponse }], + IsError = isError + }; + } + catch (Exception ex) + { + _logger.LogError(ex, "Exception thrown while calling namespace: {Namespace}, command: {Command}", namespaceName, command); + return new CallToolResult + { + Content = + [ + new TextContentBlock { + Text = $""" + There was an error finding or calling tool and command. + Failed to call namespace: {namespaceName}, command: {command} + Error: {ex.Message} + + Run again with the "learn=true" to get a list of available commands and their parameters. + """ + } + ] + }; + } + } + + private async Task InvokeToolLearn(RequestContext request, string? intent, string namespaceName, CancellationToken cancellationToken) + { + Activity.Current?.SetTag(TagName.IsServerCommandInvoked, false); + var toolsJson = GetChildToolListJson(request, namespaceName); + + var learnResponse = new CallToolResult + { + Content = + [ + new TextContentBlock { + Text = $""" + Here are the available command and their parameters for '{namespaceName}' tool. + If you do not find a suitable command, run again with the "learn=true" to get a list of available commands and their parameters. + Next, identify the command you want to execute and run again with the "command" and "parameters" arguments. + + {toolsJson} + """ + } + ], + IsError = false + }; + var response = learnResponse; + if (SupportsSampling(request.Server) && !string.IsNullOrWhiteSpace(intent)) + { + var availableTools = GetChildToolList(request, namespaceName); + (string? commandName, IReadOnlyDictionary parameters) = await GetCommandAndParametersFromIntentAsync(request, intent, namespaceName, availableTools, cancellationToken); + if (commandName != null) + { + response = await InvokeChildToolAsync(request, intent, namespaceName, commandName, parameters, cancellationToken); + } + } + return response; + } + + /// + /// Gets the available tools from the namespace commands and caches the result for subsequent requests. + /// + private List GetChildToolList(RequestContext request, string namespaceName) + { + // Check cache first + if (_cachedToolLists.TryGetValue(namespaceName, out var cachedList)) + { + return cachedList; + } + + if (string.IsNullOrWhiteSpace(request.Params?.Name)) + { + throw new ArgumentNullException(nameof(request.Params.Name), "Tool name cannot be null or empty."); + } + + var namespaces = _availableNamespaces.Value; + if (!namespaces.Any(ns => string.Equals(ns, namespaceName, StringComparison.OrdinalIgnoreCase))) + { + var availableList = string.Join(", ", namespaces); + throw new KeyNotFoundException($"The namespace '{namespaceName}' was not found. Available namespaces: {availableList}"); + } + + var namespaceCommands = _commandFactory.GroupCommands([namespaceName]); + if (namespaceCommands == null) + { + _logger.LogWarning("No commands found for namespace: {Namespace}", namespaceName); + return []; + } + + var list = namespaceCommands + .Where(kvp => !(_options.Value.ReadOnly ?? false) || kvp.Value.Metadata.ReadOnly) + .Select(kvp => CreateToolFromCommand(kvp.Key, kvp.Value)) + .ToList(); + + // Cache for subsequent requests + _cachedToolLists[namespaceName] = list; + + return list; + } + + private string GetChildToolListJson(RequestContext request, string namespaceName) + { + var listTools = GetChildToolList(request, namespaceName); + return JsonSerializer.Serialize(listTools, ServerJsonContext.Default.ListTool); + } + + private string GetChildToolJson(RequestContext request, string namespaceName, string commandName) + { + var tools = GetChildToolList(request, namespaceName); + var tool = tools.First(t => string.Equals(t.Name, commandName, StringComparison.OrdinalIgnoreCase)); + return JsonSerializer.Serialize(tool, ServerJsonContext.Default.Tool); + } + + /// + /// Creates a tool definition from a command (same logic as CommandFactoryToolLoader). + /// + private static Tool CreateToolFromCommand(string fullName, IBaseCommand command) + { + var underlyingCommand = command.GetCommand(); + var tool = new Tool + { + Name = fullName, + Description = underlyingCommand.Description, + }; + + var metadata = command.Metadata; + tool.Annotations = new ToolAnnotations() + { + DestructiveHint = metadata.Destructive, + IdempotentHint = metadata.Idempotent, + OpenWorldHint = metadata.OpenWorld, + ReadOnlyHint = metadata.ReadOnly, + Title = command.Title, + }; + + if (metadata.Secret) + { + tool.Meta = new JsonObject { ["SecretHint"] = metadata.Secret }; + } + + var schema = new ToolInputSchema(); + var options = command.GetCommand().Options; + + if (options?.Count > 0) + { + if (options.Count == 1 && IsRawMcpToolInputOption(options[0])) + { + var arguments = JsonNode.Parse(options[0].Description ?? "{}") as JsonObject ?? new JsonObject(); + tool.InputSchema = JsonSerializer.SerializeToElement(arguments, ServerJsonContext.Default.JsonObject); + return tool; + } + else + { + foreach (var option in options) + { + var propName = NameNormalization.NormalizeOptionName(option.Name); + schema.Properties.Add(propName, TypeToJsonTypeMapper.CreatePropertySchema(option.ValueType, option.Description)); + } + schema.Required = [.. options.Where(p => p.Required).Select(p => NameNormalization.NormalizeOptionName(p.Name))]; + } + } + + tool.InputSchema = JsonSerializer.SerializeToElement(schema, ServerJsonContext.Default.ToolInputSchema); + return tool; + } + + private static bool IsRawMcpToolInputOption(Option option) + { + const string RawMcpToolInputOptionName = "raw-mcp-tool-input"; + if (string.Equals(NameNormalization.NormalizeOptionName(option.Name), RawMcpToolInputOptionName, StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return option.Aliases.Any(alias => + string.Equals(NameNormalization.NormalizeOptionName(alias), RawMcpToolInputOptionName, StringComparison.OrdinalIgnoreCase)); + } + + private static IReadOnlyDictionary GetParametersFromArgs(IReadOnlyDictionary? args) + { + if (args == null || !args.TryGetValue("parameters", out var paramsElem)) + { + return new Dictionary(); + } + + if (paramsElem.ValueKind == JsonValueKind.Object) + { + return paramsElem.EnumerateObject() + .ToDictionary(prop => prop.Name, prop => prop.Value); + } + + return new Dictionary(); + } + + private static bool SupportsSampling(McpServer server) + { + return server?.ClientCapabilities?.Sampling != null; + } + + private static async Task NotifyProgressAsync(RequestContext request, string message, CancellationToken cancellationToken) + { + var progressToken = request.Params?.ProgressToken; + if (progressToken == null) + { + return; + } + + await request.Server.NotifyProgressAsync(progressToken.Value, + new ProgressNotificationValue + { + Progress = 0f, + Message = message, + }, cancellationToken); + } + + private async Task<(string? commandName, IReadOnlyDictionary parameters)> GetCommandAndParametersFromIntentAsync( + RequestContext request, + string intent, + string namespaceName, + List availableTools, + CancellationToken cancellationToken) + { + await NotifyProgressAsync(request, $"Learning about {namespaceName} capabilities...", cancellationToken); + + JsonElement toolParams = GetParametersJsonElement(request); + var toolParamsJson = toolParams.GetRawText(); + var availableToolsJson = JsonSerializer.Serialize(availableTools, ServerJsonContext.Default.ListTool); + + var samplingRequest = new CreateMessageRequestParams + { + Messages = [ + new SamplingMessage + { + Role = Role.Assistant, + Content = new TextContentBlock{ + Text = $""" + This is a list of available commands for the {namespaceName} server. + + Your task: + - Select the single command that best matches the user's intent. + - Return a valid JSON object that matches the provided result schema. + - Map the user's intent and known parameters to the command's input schema, ensuring parameter names and types match the schema exactly (no extra or missing parameters). + - Only include parameters that are defined in the selected command's input schema. + - Do not guess or invent parameters. + - If no command matches, return JSON schema with "Unknown" tool name. + + Result Schema: + {ToolCallProxySchema} + + Intent: + {intent ?? "No specific intent provided"} + + Known Parameters: + {toolParamsJson} + + Available Commands: + {availableToolsJson} + """ + } + } + ], + }; + try + { + var samplingResponse = await request.Server.SampleAsync(samplingRequest, cancellationToken); + var samplingContent = samplingResponse.Content as TextContentBlock; + var toolCallJson = samplingContent?.Text?.Trim(); + string? commandName = null; + IReadOnlyDictionary parameters = new Dictionary(); + + if (!string.IsNullOrEmpty(toolCallJson)) + { + using var jsonDoc = JsonDocument.Parse(toolCallJson); + var root = jsonDoc.RootElement; + if (root.TryGetProperty("tool", out var toolProp) && toolProp.ValueKind == JsonValueKind.String) + { + commandName = toolProp.GetString(); + } + if (root.TryGetProperty("parameters", out var parametersElem) && parametersElem.ValueKind == JsonValueKind.Object) + { + parameters = parametersElem.EnumerateObject().ToDictionary(prop => prop.Name, prop => prop.Value.Clone()) ?? new Dictionary(); + } + } + + if (commandName != null && commandName != "Unknown") + { + return (commandName, parameters); + } + } + catch + { + _logger.LogError("Failed to get command and parameters from intent: {Intent} for namespace: {Namespace}", intent, namespaceName); + } + + return (null, new Dictionary()); + } + + /// + /// Disposes resources owned by this tool loader. + /// Clears the cached tool lists dictionary. + /// + protected override ValueTask DisposeAsyncCore() + { + _cachedToolLists.Clear(); + return ValueTask.CompletedTask; + } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/RegistryToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/RegistryToolLoader.cs index e5c20cf888..41a220cdb9 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/RegistryToolLoader.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/RegistryToolLoader.cs @@ -1,11 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics; using Azure.Mcp.Core.Areas.Server.Commands.Discovery; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; +using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants; namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; @@ -21,8 +23,8 @@ public sealed class RegistryToolLoader( { private readonly IMcpDiscoveryStrategy _serverDiscoveryStrategy = discoveryStrategy; private readonly IOptions _options = options; - private Dictionary _toolClientMap = new(); - private List _discoveredClients = new(); + private Dictionary _toolClientMap = new(); + private List _discoveredClients = new(); private readonly SemaphoreSlim _initializationSemaphore = new(1, 1); private bool _isInitialized = false; @@ -54,6 +56,12 @@ public override async ValueTask ListToolsHandler(RequestContext .Select(t => t.ProtocolTool) .Where(t => !_options.Value.ReadOnly || (t.Annotations?.ReadOnlyHint == true)); + // Filter by specific tools if provided + if (_options.Value.Tool != null && _options.Value.Tool.Length > 0) + { + filteredTools = filteredTools.Where(t => _options.Value.Tool.Any(tool => tool.Contains(t.Name, StringComparison.OrdinalIgnoreCase))); + } + foreach (var tool in filteredTools) { allToolsResponse.Tools.Add(tool); @@ -90,11 +98,31 @@ public override async ValueTask CallToolHandler(RequestContext 0) + { + if (!_options.Value.Tool.Any(tool => tool.Contains(request.Params.Name, StringComparison.OrdinalIgnoreCase))) + { + var content = new TextContentBlock + { + Text = $"Tool '{request.Params.Name}' is not available. This server is configured to only expose the tools: {string.Join(", ", _options.Value.Tool.Select(t => $"'{t}'"))}", + }; + + _logger.LogWarning(content.Text); + + return new CallToolResult + { + Content = [content], + IsError = true, + }; + } + } + + if (!_toolClientMap.TryGetValue(request.Params.Name, out var kvp) || kvp.Client == null) { var content = new TextContentBlock { - Text = $"The tool {request.Params.Name} was not found", + Text = $"The tool {request.Params.Name} was not found in the tool registry.", }; _logger.LogWarning(content.Text); @@ -106,8 +134,11 @@ public override async ValueTask CallToolHandler(RequestContext @@ -147,15 +178,15 @@ private async Task InitializeAsync(CancellationToken cancellationToken) return; } - var serverList = await _serverDiscoveryStrategy.DiscoverServersAsync(); + var serverList = await _serverDiscoveryStrategy.DiscoverServersAsync(cancellationToken); foreach (var server in serverList) { var serverMetadata = server.CreateMetadata(); - IMcpClient? mcpClient; + McpClient? mcpClient; try { - mcpClient = await _serverDiscoveryStrategy.GetOrCreateClientAsync(serverMetadata.Name, ClientOptions); + mcpClient = await _serverDiscoveryStrategy.GetOrCreateClientAsync(serverMetadata.Name, ClientOptions, cancellationToken); } catch (InvalidOperationException ex) { @@ -184,7 +215,7 @@ private async Task InitializeAsync(CancellationToken cancellationToken) foreach (var tool in filteredTools) { - _toolClientMap[tool.Name] = mcpClient; + _toolClientMap[tool.Name] = (serverMetadata.Name, mcpClient); } } diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ServerToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ServerToolLoader.cs index d9ec42092b..a6b22eea91 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ServerToolLoader.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ServerToolLoader.cs @@ -1,12 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics; using Azure.Mcp.Core.Areas.Server.Commands.Discovery; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; +using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants; namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; @@ -61,7 +63,7 @@ public sealed class ServerToolLoader(IMcpDiscoveryStrategy serverDiscoveryStrate public override async ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken) { - var serverList = await _serverDiscoveryStrategy.DiscoverServersAsync(); + var serverList = await _serverDiscoveryStrategy.DiscoverServersAsync(cancellationToken); var allToolsResponse = new ListToolsResult { Tools = new List() @@ -82,6 +84,19 @@ Sub commands are routed to MCP servers that require specific fields inside the " InputSchema = ToolSchema, }; + // Set annotations if we have Title or ToolMetadata + if (metadata.Title != null || metadata.ToolMetadata != null) + { + tool.Annotations = new ToolAnnotations + { + Title = metadata.Title, + DestructiveHint = metadata.ToolMetadata?.Destructive, + IdempotentHint = metadata.ToolMetadata?.Idempotent, + OpenWorldHint = metadata.ToolMetadata?.OpenWorld, + ReadOnlyHint = metadata.ToolMetadata?.ReadOnly, + }; + } + allToolsResponse.Tools.Add(tool); } @@ -122,14 +137,23 @@ public override async ValueTask CallToolHandler(RequestContext InvokeChildToolAsync(RequestContext InvokeChildToolAsync(RequestContext InvokeChildToolAsync(RequestContext InvokeChildToolAsync(RequestContext InvokeToolLearn(RequestContext request, string? intent, string tool, CancellationToken cancellationToken) { - var toolsJson = await GetChildToolListJsonAsync(request, tool); + Activity.Current?.SetTag(TagName.IsServerCommandInvoked, false); + var toolsJson = await GetChildToolListJsonAsync(request, tool, cancellationToken); var learnResponse = new CallToolResult { @@ -324,7 +352,7 @@ private async Task InvokeToolLearn(RequestContext parameters) = await GetCommandAndParametersFromIntentAsync(request, intent, tool, availableTools, cancellationToken); if (commandName != null) { @@ -340,7 +368,7 @@ private async Task InvokeToolLearn(RequestContext /// /// - private async Task> GetChildToolListAsync(RequestContext request, string tool) + private async Task> GetChildToolListAsync(RequestContext request, string tool, CancellationToken cancellationToken) { if (_cachedToolLists.TryGetValue(tool, out var cachedList)) { @@ -353,13 +381,13 @@ private async Task> GetChildToolListAsync(RequestContext> GetChildToolListAsync(RequestContext GetChildToolListJsonAsync(RequestContext request, string tool) + private async Task GetChildToolListJsonAsync(RequestContext request, string tool, CancellationToken cancellationToken) { - var listTools = await GetChildToolListAsync(request, tool); + var listTools = await GetChildToolListAsync(request, tool, cancellationToken); return JsonSerializer.Serialize(listTools, ServerJsonContext.Default.ListTool); } - private async Task GetChildToolAsync(RequestContext request, string toolName, string commandName) + private async Task GetChildToolAsync(RequestContext request, string toolName, string commandName, CancellationToken cancellationToken) { - var tools = await GetChildToolListAsync(request, toolName); + var tools = await GetChildToolListAsync(request, toolName, cancellationToken); return tools.First(t => string.Equals(t.Name, commandName, StringComparison.OrdinalIgnoreCase)); } - private async Task GetChildToolJsonAsync(RequestContext request, string toolName, string commandName) + private async Task GetChildToolJsonAsync(RequestContext request, string toolName, string commandName, CancellationToken cancellationToken) { - var tool = await GetChildToolAsync(request, toolName, commandName); + var tool = await GetChildToolAsync(request, toolName, commandName, cancellationToken); return JsonSerializer.Serialize(tool, ServerJsonContext.Default.Tool); } - private static bool SupportsSampling(IMcpServer server) + private static bool SupportsSampling(McpServer server) { return server?.ClientCapabilities?.Sampling != null; } @@ -469,15 +497,15 @@ await request.Server.NotifyProgressAsync(progressToken.Value, Dictionary parameters = []; if (!string.IsNullOrEmpty(toolCallJson)) { - var doc = JsonDocument.Parse(toolCallJson); - var root = doc.RootElement; + using var jsonDoc = JsonDocument.Parse(toolCallJson); + var root = jsonDoc.RootElement; if (root.TryGetProperty("tool", out var toolProp) && toolProp.ValueKind == JsonValueKind.String) { commandName = toolProp.GetString(); } if (root.TryGetProperty("parameters", out var parametersElem) && parametersElem.ValueKind == JsonValueKind.Object) { - parameters = parametersElem.EnumerateObject().ToDictionary(prop => prop.Name, prop => (object?)prop.Value) ?? []; + parameters = parametersElem.EnumerateObject().ToDictionary(prop => prop.Name, prop => (object?)prop.Value.Clone()) ?? []; } } if (commandName != null && commandName != "Unknown") diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/SingleProxyToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/SingleProxyToolLoader.cs index 6f31f913ec..577aa5eaaa 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/SingleProxyToolLoader.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/SingleProxyToolLoader.cs @@ -1,11 +1,14 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics; using Azure.Mcp.Core.Areas.Server.Commands.Discovery; +using Azure.Mcp.Core.Services.Telemetry; using Microsoft.Extensions.Logging; using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; +using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants; namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; @@ -130,11 +133,11 @@ public override async ValueTask CallToolHandler(RequestContext GetRootToolsJsonAsync() + /// + /// Gets all of the 's available in the server. + /// + /// A JSON serialized string with each area's name and a description of operations available in + /// that namespace. + private async Task GetRootToolsJsonAsync(CancellationToken cancellationToken) { if (_cachedRootToolsJson != null) { return _cachedRootToolsJson; } - var serverList = await _discoveryStrategy.DiscoverServersAsync(); + var serverList = await _discoveryStrategy.DiscoverServersAsync(cancellationToken); var tools = new List(serverList.Count()); foreach (var server in serverList) { @@ -184,7 +192,13 @@ private async Task GetRootToolsJsonAsync() return toolsJson; } - private async Task GetToolListJsonAsync(RequestContext request, string tool) + /// + /// Gets the set of within an . + /// + /// Calling request + /// Name of the to get commands for. + /// JSON serialized string representing the list of commands available in the tool's area. + private async Task GetToolListJsonAsync(RequestContext request, string tool, CancellationToken cancellationToken) { if (_cachedToolListsJson.TryGetValue(tool, out var cachedJson)) { @@ -192,8 +206,8 @@ private async Task GetToolListJsonAsync(RequestContext GetToolListJsonAsync(RequestContext RootLearnModeAsync(RequestContext request, string intent, CancellationToken cancellationToken) { - var toolsJson = await GetRootToolsJsonAsync(); + Activity.Current?.SetTag(TagName.IsServerCommandInvoked, false); + var toolsJson = await GetRootToolsJsonAsync(cancellationToken); var learnResponse = new CallToolResult { Content = @@ -232,7 +247,11 @@ Here are the available list of tools. private async Task ToolLearnModeAsync(RequestContext request, string intent, string tool, CancellationToken cancellationToken) { - var toolsJson = await GetToolListJsonAsync(request, tool); + var activity = Activity.Current? + .SetTag(TagName.IsServerCommandInvoked, false) + .SetTag(TagName.ToolArea, tool); + + var toolsJson = await GetToolListJsonAsync(request, tool, cancellationToken); if (string.IsNullOrEmpty(toolsJson)) { return await RootLearnModeAsync(request, intent, cancellationToken); @@ -268,12 +287,12 @@ private async Task ToolLearnModeAsync(RequestContext CommandModeAsync(RequestContext request, string intent, string tool, string command, Dictionary parameters, CancellationToken cancellationToken) { - IMcpClient? client; + McpClient? client; try { var clientOptions = CreateClientOptions(request.Server); - client = await _discoveryStrategy.GetOrCreateClientAsync(tool, clientOptions); + client = await _discoveryStrategy.GetOrCreateClientAsync(tool, clientOptions, cancellationToken); if (client == null) { _logger.LogError("Failed to get provider client for tool: {Tool}", tool); @@ -286,6 +305,14 @@ private async Task CommandModeAsync(RequestContext parameters = []; if (!string.IsNullOrEmpty(toolCallJson)) { - var doc = JsonDocument.Parse(toolCallJson); - var root = doc.RootElement; + using var jsonDoc = JsonDocument.Parse(toolCallJson); + var root = jsonDoc.RootElement; if (root.TryGetProperty("tool", out var toolProp) && toolProp.ValueKind == JsonValueKind.String) { commandName = toolProp.GetString(); } if (root.TryGetProperty("parameters", out var paramsProp) && paramsProp.ValueKind == JsonValueKind.Object) { - parameters = paramsProp.EnumerateObject().ToDictionary(prop => prop.Name, prop => (object?)prop.Value); + parameters = paramsProp.EnumerateObject().ToDictionary(prop => prop.Name, prop => (object?)prop.Value.Clone()); } } if (commandName != null && commandName != "Unknown") diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ToolLoaderOptions.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ToolLoaderOptions.cs index 3a8642301c..dfaeb17856 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ToolLoaderOptions.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ToolLoaderOptions.cs @@ -10,4 +10,5 @@ namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; /// The namespaces to filter commands by. If null or empty, all commands will be included. /// Whether the tool loader should operate in read-only mode. When true, only tools marked as read-only will be exposed. /// Whether elicitation is disabled (insecure mode). When true, elicitation will always be treated as accepted. -public sealed record ToolLoaderOptions(string[]? Namespace = null, bool ReadOnly = false, bool InsecureDisableElicitation = false); +/// The specific tool names to filter by. When specified, only these tools will be exposed. +public sealed record ToolLoaderOptions(string[]? Namespace = null, bool ReadOnly = false, bool InsecureDisableElicitation = false, string[]? Tool = null); diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Models/ConsolidatedToolDefinition.cs b/core/Azure.Mcp.Core/src/Areas/Server/Models/ConsolidatedToolDefinition.cs new file mode 100644 index 0000000000..39105bf2a4 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Models/ConsolidatedToolDefinition.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Azure.Mcp.Core.Commands; + +namespace Azure.Mcp.Core.Areas.Server.Models; + +/// +/// Represents a composite tool definition that groups multiple related Azure operations together. +/// Used to create consolidated tools from the azure_mcp_consolidated_tools JSON configuration. +/// +public sealed class ConsolidatedToolDefinition +{ + /// + /// Gets or sets the name of the composite tool. + /// + [JsonPropertyName("name")] + public required string Name { get; init; } + + /// + /// Gets or sets the description of the composite tool's capabilities and purpose. + /// + [JsonPropertyName("description")] + public required string Description { get; init; } + + /// + /// Gets or sets the tool metadata containing capability information. + /// + [JsonPropertyName("toolMetadata")] + public required ToolMetadata ToolMetadata { get; init; } + + /// + /// Gets or sets the list of tool names that are mapped to this consolidated tool. + /// + [JsonPropertyName("mappedToolList")] + public required HashSet MappedToolList { get; init; } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Models/OAuthProtectedResourceMetadata.cs b/core/Azure.Mcp.Core/src/Areas/Server/Models/OAuthProtectedResourceMetadata.cs new file mode 100644 index 0000000000..728b50ff60 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Models/OAuthProtectedResourceMetadata.cs @@ -0,0 +1,35 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Core.Areas.Server.Models; + +/// +/// OAuth 2.0 protected resource metadata response model. See https://datatracker.ietf.org/doc/rfc9728/. +/// +public sealed class OAuthProtectedResourceMetadata +{ + [JsonPropertyName("resource")] + public required string Resource { get; init; } + + [JsonPropertyName("authorization_servers")] + public required string[] AuthorizationServers { get; init; } + + [JsonPropertyName("scopes_supported")] + public required string[] ScopesSupported { get; init; } + + [JsonPropertyName("bearer_methods_supported")] + public required string[] BearerMethodsSupported { get; init; } + + [JsonPropertyName("resource_documentation")] + public required string ResourceDocumentation { get; init; } +} + +/// +/// JSON serializer context for AOT-safe serialization. +/// +[JsonSerializable(typeof(OAuthProtectedResourceMetadata))] +internal partial class OAuthMetadataJsonContext : JsonSerializerContext +{ +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Models/RegistryServerInfo.cs b/core/Azure.Mcp.Core/src/Areas/Server/Models/RegistryServerInfo.cs index 74a161a0a8..c17519da55 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Models/RegistryServerInfo.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Models/RegistryServerInfo.cs @@ -30,6 +30,12 @@ public sealed class RegistryServerInfo [JsonPropertyName("description")] public string? Description { get; init; } + /// + /// Gets the user-friendly title for the server. + /// + [JsonPropertyName("title")] + public string? Title { get; init; } + /// /// Gets the transport type, e.g., "stdio". /// @@ -53,4 +59,10 @@ public sealed class RegistryServerInfo /// [JsonPropertyName("env")] public Dictionary? Env { get; init; } + + /// + /// Gets installation instructions for the server. + /// + [JsonPropertyName("installInstructions")] + public string? InstallInstructions { get; init; } } diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Options/ModeTypes.cs b/core/Azure.Mcp.Core/src/Areas/Server/Options/ModeTypes.cs index 792a1cd373..6fd9355a24 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Options/ModeTypes.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Options/ModeTypes.cs @@ -23,4 +23,9 @@ internal static class ModeTypes /// All tools mode - exposes all Azure MCP tools individually (one tool per command). /// public const string All = "all"; + + /// + /// Consolidated tools mode - exposes consolidated tools that group related Azure operations together. + /// + public const string ConsolidatedProxy = "consolidated"; } diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Options/OutgoingAuthStrategy.cs b/core/Azure.Mcp.Core/src/Areas/Server/Options/OutgoingAuthStrategy.cs new file mode 100644 index 0000000000..528f7e23e6 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Options/OutgoingAuthStrategy.cs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Identity; + +namespace Azure.Mcp.Core.Areas.Server.Options; + +/// +/// The strategy to use for authenticating outgoing requests to downstream services. +/// +public enum OutgoingAuthStrategy +{ + /// + /// The value is not set and is in a default state. A safe default will + /// be chosen based on other settings. + /// + NotSet = 0, + + /// + /// Outgoing requests will use the hosting environment's identity resolving + /// in a similar way as . This is valid + /// for all hosting scenarios. This means all outgoing requests will use the + /// same identity regardless of the incoming authenticate request identity, + /// if any. + /// + UseHostingEnvironmentIdentity = 1, + + /// + /// Outgoing requests will be authenticated based on exchanging the incoming + /// request's access token for a new access token valid for the downstream + /// service. This is only valid for remote MCP server scenarios. + /// + UseOnBehalfOf = 2 +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceOptionDefinitions.cs b/core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceOptionDefinitions.cs index e5711de39b..4b554e79e9 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceOptionDefinitions.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceOptionDefinitions.cs @@ -8,10 +8,12 @@ public static class ServiceOptionDefinitions public const string TransportName = "transport"; public const string NamespaceName = "namespace"; public const string ModeName = "mode"; + public const string ToolName = "tool"; public const string ReadOnlyName = "read-only"; public const string DebugName = "debug"; - public const string EnableInsecureTransportsName = "enable-insecure-transports"; + public const string DangerouslyDisableHttpIncomingAuthName = "dangerously-disable-http-incoming-auth"; public const string InsecureDisableElicitationName = "insecure-disable-elicitation"; + public const string OutgoingAuthStrategyName = "outgoing-auth-strategy"; public static readonly Option Transport = new($"--{TransportName}") { @@ -41,6 +43,17 @@ public static class ServiceOptionDefinitions DefaultValueFactory = _ => (string?)ModeTypes.NamespaceProxy }; + public static readonly Option Tool = new Option( + $"--{ToolName}" + ) + { + Description = "Expose only specific tools by name (e.g., 'acr_registry_list'). Repeat this option to include multiple tools, e.g., --tool \"acr_registry_list\" --tool \"group_list\". It automatically switches to \"all\" mode when \"--tool\" is used. It can't be used together with \"--namespace\".", + Required = false, + Arity = ArgumentArity.OneOrMore, + AllowMultipleArgumentsPerToken = true, + DefaultValueFactory = _ => null + }; + public static readonly Option ReadOnly = new( $"--{ReadOnlyName}") { @@ -55,12 +68,11 @@ public static class ServiceOptionDefinitions DefaultValueFactory = _ => false }; - public static readonly Option EnableInsecureTransports = new( - $"--{EnableInsecureTransportsName}") + public static readonly Option DangerouslyDisableHttpIncomingAuth = new( + $"--{DangerouslyDisableHttpIncomingAuthName}") { Required = false, - Hidden = true, - Description = "Enable insecure transport", + Description = "Dangerously disables HTTP incoming authentication, exposing the server to unauthenticated access over HTTP. Use with extreme caution, this disables all transport security and may expose sensitive data to interception.", DefaultValueFactory = _ => false }; @@ -71,4 +83,12 @@ public static class ServiceOptionDefinitions Description = "Disable elicitation (user confirmation) before allowing high risk commands to run, such as returning Secrets (passwords) from KeyVault.", DefaultValueFactory = _ => false }; + + public static readonly Option OutgoingAuthStrategy = new( + $"--{OutgoingAuthStrategyName}") + { + Required = false, + Description = "Outgoing authentication strategy for Azure service requests. Valid values: NotSet, UseHostingEnvironmentIdentity, UseOnBehalfOf.", + DefaultValueFactory = _ => Options.OutgoingAuthStrategy.NotSet + }; } diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceStartOptions.cs b/core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceStartOptions.cs index ece2b71d33..8023c60605 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceStartOptions.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Options/ServiceStartOptions.cs @@ -31,6 +31,13 @@ public class ServiceStartOptions [JsonPropertyName("mode")] public string? Mode { get; set; } = ModeTypes.NamespaceProxy; + /// + /// Gets or sets the specific tool names to expose. + /// When specified, only these tools will be available. + /// + [JsonPropertyName("tool")] + public string[]? Tool { get; set; } = null; + /// /// Gets or sets whether the server should operate in read-only mode. /// When true, only tools marked as read-only will be available. @@ -46,10 +53,11 @@ public class ServiceStartOptions public bool Debug { get; set; } = false; /// - /// Gets or sets whether insecure transport mechanisms are enabled. + /// Gets or sets whether HTTP incoming authentication is disabled. + /// When true, the server accepts unauthenticated HTTP requests. /// - [JsonPropertyName("enableInsecureTransports")] - public bool EnableInsecureTransports { get; set; } = false; + [JsonPropertyName("dangerouslyDisableHttpIncomingAuth")] + public bool DangerouslyDisableHttpIncomingAuth { get; set; } = false; /// /// Gets or sets whether elicitation (user confirmation for high-risk operations like accessing secrets) is disabled (insecure mode). @@ -57,4 +65,11 @@ public class ServiceStartOptions /// [JsonPropertyName("insecureDisableElicitation")] public bool InsecureDisableElicitation { get; set; } = false; + + /// + /// Gets or sets the outgoing authentication strategy for Azure service requests. + /// Determines whether to use hosting environment identity or on-behalf-of flow. + /// + [JsonPropertyName("outgoingAuthStrategy")] + public OutgoingAuthStrategy OutgoingAuthStrategy { get; set; } = OutgoingAuthStrategy.NotSet; } diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Options/TransportTypes.cs b/core/Azure.Mcp.Core/src/Areas/Server/Options/TransportTypes.cs index ff5eba5326..51002fa5d1 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Options/TransportTypes.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Options/TransportTypes.cs @@ -12,4 +12,9 @@ internal static class TransportTypes /// Standard Input/Output transport mechanism. /// public const string StdIo = "stdio"; + + /// + /// MCP's bespoke transport called Streamable HTTP. + /// + public const string Http = "http"; } diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Resources/azure-rules.txt b/core/Azure.Mcp.Core/src/Areas/Server/Resources/azure-rules.txt index 9cb8d37d18..2ce9212a88 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Resources/azure-rules.txt +++ b/core/Azure.Mcp.Core/src/Areas/Server/Resources/azure-rules.txt @@ -12,8 +12,13 @@ sure the user is discussing Azure Functions; do not call it otherwise. - Use Azure Functions Deployment Best Practices: When deploying Azure Functions apps to Azure or preparing for the deployment, invoke your `bestpractices` tool if available. Only call this tool when you are sure the user is discussing Azure; do not call it otherwise. +- Use Azure AI Code Generation Best Practices: When generating code for AI applications, AI agents, workflows, +or chatbots in Azure, invoke your `azureaibestpractices` tool if available. Only call this tool when you are sure +the user is discussing Azure and code generation; do not call it for Azure resource operations. - Use Azure SWA Best Practices: When working with static web apps, invoke your `bestpractices` tool if available. Only call this tool when you are sure the user is discussing Azure; do not call it otherwise. - Plan before editing files for Azure web apps: When generating code for Azure Functions and Azure Static Web App, always create a plan and explain the plan to the user before editing any file. Proceed with editing the files -after getting consent from the user. \ No newline at end of file +after getting consent from the user. +- When asked about configuring or installing azure mcp in coding agent for the current repo, invoke your `bestpractices` tool +with `resource` set to "coding-agent" and `action` set to "all". \ No newline at end of file diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Resources/consolidated-tools.json b/core/Azure.Mcp.Core/src/Areas/Server/Resources/consolidated-tools.json new file mode 100644 index 0000000000..2c84477d24 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Resources/consolidated-tools.json @@ -0,0 +1,2234 @@ +{ + "consolidated_tools": [ + { + "name": "get_azure_subscriptions_and_resource_groups", + "description": "Get information about Azure subscriptions and resource groups that the user has access to.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "group_list", + "subscription_list" + ] + }, + { + "name": "get_azure_app_resource_details", + "description": "Get details about Azure application platform services, such as Azure Functions.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "functionapp_get" + ] + }, + { + "name": "add_azure_app_service_database", + "description": "Add and configure database integrations for Azure App Service applications.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": true, + "description": "This tool may interact with an unpredictable or dynamic set of external entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "appservice_database_add" + ] + }, + { + "name": "get_azure_databases_details", + "description": "Comprehensive Azure database management tool for MySQL, PostgreSQL, SQL Database, SQL Server, and Cosmos DB. List and query databases, retrieve server configurations and parameters, explore table schemas, execute database queries, manage Cosmos DB containers and items, list and view detailed information about SQL servers, and view database server details across all Azure database services.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "mysql_database_list", + "mysql_database_query", + "mysql_server_config_get", + "mysql_server_list", + "mysql_server_param_get", + "mysql_table_list", + "mysql_table_schema_get", + "postgres_database_list", + "postgres_database_query", + "postgres_server_config_get", + "postgres_server_list", + "postgres_server_param_get", + "postgres_table_list", + "postgres_table_schema_get", + "sql_db_list", + "sql_db_show", + "sql_server_list", + "sql_server_show", + "cosmos_account_list", + "cosmos_database_container_item_query", + "cosmos_database_container_list", + "cosmos_database_list" + ] + }, + { + "name": "create_azure_sql_databases_and_servers", + "description": "Create new Azure SQL databases and SQL servers with configurable performance tiers and settings.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "sql_db_create", + "sql_server_create" + ] + }, + { + "name": "rename_azure_sql_databases", + "description": "Rename Azure SQL databases to a new name within the same SQL server.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "sql_db_rename" + ] + }, + { + "name": "edit_azure_sql_databases_and_servers", + "description": "Update and delete Azure SQL databases and SQL servers. Modify database configurations or permanently remove servers and databases.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "sql_db_update", + "sql_db_delete", + "sql_server_delete" + ] + }, + { + "name": "edit_azure_databases", + "description": "Edit Azure MySQL and PostgreSQL database server parameters", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "mysql_server_param_set", + "postgres_server_param_set" + ] + }, + { + "name": "get_azure_resource_and_app_health_status", + "description": "Get Azure resource and application health status, metrics, availability, and service health events. Query Log Analytics, list Log Analytics workspaces, list activity logs, get the current availability status of Azure resources, list service health events and incidents, list Grafana instances, view Datadog monitored resources, perform App Lens diagnostics for comprehensive application troubleshooting, retrieve Application Insights recommendations for performance optimization, manage web tests for application monitoring, and get health status of entities in Azure Monitor health models.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "applicationinsights_recommendation_list", + "applens_resource_diagnose", + "grafana_list", + "datadog_monitoredresources_list", + "monitor_workspace_list", + "monitor_activitylog_list", + "monitor_healthmodels_entity_get", + "monitor_metrics_definitions", + "monitor_metrics_query", + "monitor_resource_log_query", + "monitor_table_list", + "monitor_table_type_list", + "monitor_webtests_get", + "monitor_webtests_list", + "monitor_workspace_log_query", + "resourcehealth_availability-status_get", + "resourcehealth_availability-status_list", + "resourcehealth_health-events_list" + ] + }, + { + "name": "create_azure_monitor_webtests", + "description": "Create Azure Monitor web tests for application availability monitoring.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "monitor_webtests_create" + ] + }, + { + "name": "update_azure_monitor_webtests", + "description": "Update Azure Monitor web tests for application availability monitoring.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "monitor_webtests_update" + ] + }, + { + "name": "deploy_azure_resources_and_applications", + "description": "Deploy resources and applications to Azure. Retrieve application logs, access Bicep and Terraform rules, get CI/CD pipeline guidance, and generate deployment plans.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "deploy_app_logs_get", + "deploy_iac_rules_get", + "deploy_pipeline_guidance_get", + "deploy_plan_get" + ] + }, + { + "name": "deploy_azure_ai_models", + "description": "Deploy AI models to Microsoft Foundry for machine learning inference and production use.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "foundry_models_deploy" + ] + }, + { + "name": "get_azure_app_config_settings", + "description": "Get details about Azure App Configuration settings", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "appconfig_account_list", + "appconfig_kv_get" + ] + }, + { + "name": "edit_azure_app_config_settings", + "description": "Delete or set Azure App Configuration settings with write operations.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "appconfig_kv_delete", + "appconfig_kv_set" + ] + }, + { + "name": "lock_unlock_azure_app_config_settings", + "description": "Lock and unlock Azure App Configuration settings", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "appconfig_kv_lock_set" + ] + }, + { + "name": "edit_azure_workbooks", + "description": "Update or delete Azure Monitor Workbooks", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "workbooks_delete", + "workbooks_update" + ] + }, + { + "name": "create_azure_workbooks", + "description": "Create new Azure Monitor Workbooks", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "workbooks_create" + ] + }, + { + "name": "get_azure_workbooks_details", + "description": "Get details about Azure Monitor Workbooks including listing workbooks and viewing specific workbook configurations.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "workbooks_list", + "workbooks_show" + ] + }, + { + "name": "audit_azure_resources_compliance", + "description": "Generate compliance and security audit reports for Azure resources using Azure Quick Review (azqr).", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "extension_azqr" + ] + }, + { + "name": "generate_azure_cli_commands", + "description": "Generate Azure CLI commands from natural language descriptions to help answer questions about Azure environments and operations", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "extension_cli_generate" + ] + }, + { + "name": "install_azure_cli_extensions", + "description": "Install Azure CLI extensions to extend Azure CLI functionality with additional commands and features.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": true, + "description": "This tool is only available when the Azure MCP server is configured to run as a Local MCP Server (STDIO)." + } + }, + "mappedToolList": [ + "extension_cli_install" + ] + }, + { + "name": "get_azure_security_configurations", + "description": "List Azure RBAC role assignments", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "role_assignment_list" + ] + }, + { + "name": "get_azure_key_vault_items", + "description": "View and retrieve Azure Key Vault security artifacts, including certificates, keys (both listing and individual key details), secret names and properties (listing only, not secret values), and admin settings.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "keyvault_certificate_get", + "keyvault_certificate_list", + "keyvault_key_get", + "keyvault_key_list", + "keyvault_secret_list", + "keyvault_admin_settings_get" + ] + }, + { + "name": "get_azure_key_vault_secret_values", + "description": "Retrieve the actual secret values from Azure Key Vault. Use this tool when you need to access the sensitive content of a secret, not just its metadata or properties.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": true, + "description": "This tool handles sensitive data such as secrets, credentials, keys, or other confidential information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "keyvault_secret_get" + ] + }, + { + "name": "create_azure_key_vault_items", + "description": "Create new security artifacts in Azure Key Vault including certificates and keys.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "keyvault_certificate_create", + "keyvault_key_create" + ] + }, + { + "name": "create_azure_key_vault_secrets", + "description": "Create new secrets in Azure Key Vault.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": true, + "description": "This tool handles sensitive data such as secrets, credentials, keys, or other confidential information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "keyvault_secret_create" + ] + }, + { + "name": "import_azure_key_vault_certificates", + "description": "Import external certificates into Azure Key Vault", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": true, + "description": "This tool is only available when the Azure MCP server is configured to run as a Local MCP Server (STDIO)." + } + }, + "mappedToolList": [ + "keyvault_certificate_import" + ] + }, + { + "name": "get_azure_best_practices", + "description": "Retrieve Azure best practices and infrastructure schema for code generation, deployment, and operations. Covers general Azure practices, Azure Functions best practices, AI app development best practices, Terraform configurations, Bicep template schemas, deployment best practices and Microsoft Foundry sdk code samples.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "azureaibestpractices_get", + "azureterraformbestpractices_get", + "bicepschema_get", + "get_bestpractices_get", + "foundry_agents_get-sdk-sample" + ] + }, + { + "name": "design_azure_architecture", + "description": "Comprehensive Azure architecture design and visualization tool. Provide cloud architecture consulting and recommend optimal Azure solutions following Well-Architected Framework principles. Also generates visual Mermaid diagrams from application topologies.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "cloudarchitect_design", + "deploy_architecture_diagram_generate" + ] + }, + { + "name": "get_azure_load_testing_details", + "description": "Get Azure Load Testing test configurations, results, and test resources including listing load testing resources.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "loadtesting_testresource_list", + "loadtesting_test_get", + "loadtesting_testrun_get", + "loadtesting_testrun_list" + ] + }, + { + "name": "create_azure_load_testing", + "description": "Create Load Testing resource or execute Azure Load Testing tests.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "loadtesting_test_create", + "loadtesting_testresource_create", + "loadtesting_testrun_create" + ] + }, + { + "name": "update_azure_load_testing_configurations", + "description": "Update Azure Load Testing configurations and test run settings.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "loadtesting_testrun_update" + ] + }, + { + "name": "get_azure_ai_resources_details", + "description": "Get details about Azure AI resources including listing and querying AI Search services, listing models available to be deployed and models deployed already, knowledge index schema by Microsoft Foundry, knowledge base and source information, and listing Microsoft Foundry , threads, messages and resources.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "search_service_list", + "search_index_get", + "search_index_query", + "search_knowledge_source_get", + "search_knowledge_base_get", + "foundry_models_deployments_list", + "foundry_models_list", + "foundry_knowledge_index_list", + "foundry_knowledge_index_schema", + "foundry_openai_models-list", + "foundry_agents_list", + "foundry_resource_get", + "foundry_threads_list", + "foundry_threads_get-messages" + ] + }, + { + "name": "retrieve_azure_ai_knowledge_base_content", + "description": "Retrieve content from Azure AI Search knowledge bases using semantic search queries to find relevant information.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": true, + "description": "This tool may interact with an unpredictable or dynamic set of external entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "search_knowledge_base_retrieve" + ] + }, + { + "name": "use_azure_openai_models", + "description": "Generate text completions, chat responses, and embeddings using Azure OpenAI models in Microsoft Foundry. Create conversational AI interactions and vector embeddings for semantic search and analysis.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "foundry_openai_create-completion", + "foundry_openai_embeddings-create", + "foundry_openai_chat-completions-create" + ] + }, + { + "name": "create_foundry_agent_resources", + "description": "Create Microsoft Foundry agent or thread.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "foundry_agents_create", + "foundry_threads_create" + ] + }, + { + "name": "connect_foundry_agents", + "description": "Connect to Microsoft Foundry agents for establishing agent connections and communication channels.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": true, + "description": "This tool may interact with an unpredictable or dynamic set of external entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "foundry_agents_connect" + ] + }, + { + "name": "query_and_evaluate_foundry_agents", + "description": "Query Microsoft Foundry agents with prompts and evaluate their responses using various metrics for comprehensive performance assessment.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": true, + "description": "This tool may interact with an unpredictable or dynamic set of external entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "foundry_agents_query-and-evaluate" + ] + }, + { + "name": "evaluate_foundry_agents", + "description": "Evaluate Microsoft Foundry agents for performance assessment and testing of agent capabilities using evaluation metrics.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "foundry_agents_evaluate" + ] + }, + { + "name": "get_azure_storage_details", + "description": "Get details about Azure Storage resources including Storage accounts, blob containers, and blob data. Also manage Azure Managed Lustre (AMLFS) filesystems: list filesystems with provisioning state and capacity, retrieve available SKUs with bandwidth and scale targets, calculate required subnet sizes for deployment planning, and validate subnet configurations against SKU and size requirements.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "managedlustre_fs_list", + "managedlustre_fs_sku_get", + "storage_account_get", + "storage_blob_container_get", + "storage_blob_get", + "managedlustre_fs_subnetsize_ask", + "managedlustre_fs_subnetsize_validate" + ] + }, + { + "name": "create_azure_storage", + "description": "Create Azure Storage accounts, blob containers, and Azure Managed Lustre filesystems.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "storage_account_create", + "storage_blob_container_create", + "managedlustre_fs_create" + ] + }, + { + "name": "update_azure_managed_lustre_filesystems", + "description": "Update existing Azure Managed Lustre filesystem configurations.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "managedlustre_fs_update" + ] + }, + { + "name": "upload_azure_storage_blobs", + "description": "Upload files and data to Azure Storage blob containers.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": true, + "description": "This tool is only available when the Azure MCP server is configured to run as a Local MCP Server (STDIO)." + } + }, + "mappedToolList": [ + "storage_blob_upload" + ] + }, + { + "name": "get_azure_cache_for_redis_details", + "description": "Get details about Azure Redis resources including Azure Managed Redis, Azure Cache for Redis, and Azure Redis Enterprise resources.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "redis_list" + ] + }, + { + "name": "create_azure_cache_for_redis", + "description": "Create new Azure Managed Redis resources with configurable SKU, location, and authentication settings.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "redis_create" + ] + }, + { + "name": "browse_azure_marketplace_products", + "description": "Browse products and offers in the Azure Marketplace", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "marketplace_product_list", + "marketplace_product_get" + ] + }, + { + "name": "get_azure_capacity", + "description": "Check Azure resource capacity, quotas, available regions, and usage limits", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "quota_region_availability_list", + "quota_usage_check" + ] + }, + { + "name": "get_azure_messaging_service_details", + "description": "Get details about Azure messaging services including Service Bus queues, topics, subscriptions, Event Grid topics and subscriptions, and Event Hubs namespaces, event hubs, consumer groups, and streaming event ingestion resources.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "eventgrid_topic_list", + "eventgrid_subscription_list", + "servicebus_queue_details", + "servicebus_topic_details", + "servicebus_topic_subscription_details", + "eventhubs_namespace_get", + "eventhubs_eventhub_get", + "eventhubs_eventhub_consumergroup_get" + ] + }, + { + "name": "edit_azure_data_analytics_resources", + "description": "Update and delete Azure Event Hubs resources including namespaces, event hubs, and consumer groups.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "eventhubs_namespace_update", + "eventhubs_namespace_delete", + "eventhubs_eventhub_update", + "eventhubs_eventhub_delete", + "eventhubs_eventhub_consumergroup_update", + "eventhubs_eventhub_consumergroup_delete" + ] + }, + { + "name": "publish_azure_eventgrid_events", + "description": "Publish custom events to Azure Event Grid topics for event-driven architectures with schema validation and delivery guarantees.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "eventgrid_events_publish" + ] + }, + { + "name": "get_azure_data_explorer_kusto_details", + "description": "Get details about Azure Data Explorer (Kusto). List clusters, execute KQL queries, manage databases, explore table schemas, and get data samples.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "kusto_cluster_list", + "kusto_cluster_get", + "kusto_database_list", + "kusto_query", + "kusto_sample", + "kusto_table_list", + "kusto_table_schema" + ] + }, + { + "name": "create_azure_database_admin_configurations", + "description": "Create Azure SQL Server firewall rules", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "sql_server_firewall-rule_create" + ] + }, + { + "name": "delete_azure_database_admin_configurations", + "description": "Delete Azure SQL Server firewall rules", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool may delete or modify existing resources in its environment." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "sql_server_firewall-rule_delete" + ] + }, + { + "name": "get_azure_database_admin_configuration_details", + "description": "Get details about Azure SQL Server Administration configurations including elastic pools, Entra admin assignments, and firewall rules.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "sql_elastic-pool_list", + "sql_server_entra-admin_list", + "sql_server_firewall-rule_list" + ] + }, + { + "name": "get_azure_container_details", + "description": "Get details about Azure container services including Azure Container Registry (ACR) and Azure Kubernetes Service (AKS). View registries, repositories, nodepools, clusters, cluster configurations, and individual nodepool details.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "acr_registry_list", + "acr_registry_repository_list", + "aks_cluster_get", + "aks_nodepool_get" + ] + }, + { + "name": "get_azure_virtual_desktop_details", + "description": "Get details about Azure Virtual Desktop resources. List host pools in subscriptions or resource groups. Retrieve session hosts (virtual machines) within host pools including their status, availability, and configuration. View active user sessions on session hosts with details such as user principal name, session state, application type, and creation time.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "virtualdesktop_hostpool_list", + "virtualdesktop_hostpool_host_list", + "virtualdesktop_hostpool_host_user-list" + ] + }, + { + "name": "get_azure_signalr_details", + "description": "Get details about Azure SignalR Service resources including runtime information, identity, network ACLs, and upstream templates.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "signalr_runtime_get" + ] + }, + { + "name": "get_azure_confidential_ledger_entries", + "description": "Retrieve tamper-proof entries from Azure Confidential Ledger instances.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "confidentialledger_entries_get" + ] + }, + { + "name": "append_azure_confidential_ledger_entries", + "description": "Append tamper-proof entries to Azure Confidential Ledger instances and retrieve transaction identifiers.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "confidentialledger_entries_append" + ] + }, + { + "name": "send_azure_communication_messages", + "description": "Send SMS and email messages to one or more recipients using Azure Communication Services with delivery status tracking.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }, + "openWorld": { + "value": true, + "description": "This tool may interact with an unpredictable or dynamic set of external entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "communication_sms_send", + "communication_email_send" + ] + }, + { + "name": "recognize_speech_from_audio", + "description": "Convert speech from audio files to text using Azure AI Services Speech recognition. Supports various audio formats including WAV, MP3, OPUS/OGG, FLAC, and more.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": true, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": true, + "description": "This tool is only available when the Azure MCP server is configured to run as a Local MCP Server (STDIO)." + } + }, + "mappedToolList": [ + "speech_stt_recognize" + ] + }, + { + "name": "synthesize_text_to_speech", + "description": "Convert text to speech using Azure AI Services Speech. Generate audio files with neural voices and customizable output formats including WAV, MP3, OGG, and RAW. Supports multiple languages, voice selection, and custom voice models.", + "toolMetadata": { + "destructive": { + "value": false, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": true, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool may modify its environment by creating, updating, or deleting data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": true, + "description": "This tool is only available when the Azure MCP server is configured to run as a Local MCP Server (STDIO)." + } + }, + "mappedToolList": [ + "speech_tts_synthesize" + ] + }, + { + "name": "create_azure_app_resource", + "description": "Create Azure application platform services, such as Azure Functions.", + "toolMetadata": { + "destructive": { + "value": true, + "description": "This tool performs only additive updates without deleting or modifying existing resources." + }, + "idempotent": { + "value": false, + "description": "Running this operation multiple times with the same arguments produces the same result without additional effects." + }, + "openWorld": { + "value": false, + "description": "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities." + }, + "readOnly": { + "value": false, + "description": "This tool only performs read operations without modifying any state or data." + }, + "secret": { + "value": false, + "description": "This tool does not handle sensitive or secret information." + }, + "localRequired": { + "value": false, + "description": "This tool is available in both local and remote server modes." + } + }, + "mappedToolList": [ + "functionapp_create" + ] + } + ] +} \ No newline at end of file diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Resources/registry.json b/core/Azure.Mcp.Core/src/Areas/Server/Resources/registry.json index 50566dfd46..27f63ab727 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Resources/registry.json +++ b/core/Azure.Mcp.Core/src/Areas/Server/Resources/registry.json @@ -2,13 +2,16 @@ "servers": { "documentation": { "url": "https://learn.microsoft.com/api/mcp", + "title": "Microsoft Documentation Search", "description": "Search official Microsoft/Azure documentation to find the most relevant and trustworthy content for a user's query. This tool returns up to 10 high-quality content chunks (each max 500 tokens), extracted from Microsoft Learn and other official sources. Each result includes the article title, URL, and a self-contained content excerpt optimized for fast retrieval and reasoning. Always use this tool to quickly ground your answers in accurate, first-party Microsoft/Azure knowledge." }, "azd": { "type": "stdio", "command": "azd", "args": ["mcp", "start"], - "description": "Azure Developer CLI (azd) includes a suite of tools to help build, modernize, and manage applications on Azure. It simplifies the process of developing cloud applications by providing commands for project initialization, resource provisioning, deployment, and monitoring. Use this tool to streamline your Azure development workflow and manage your cloud resources efficiently." + "title": "Azure Developer CLI", + "description": "Azure Developer CLI (azd) includes a suite of tools to help build, modernize, and manage applications on Azure. It simplifies the process of developing cloud applications by providing commands for project initialization, resource provisioning, deployment, and monitoring. Use this tool to streamline your Azure development workflow and manage your cloud resources efficiently.", + "installInstructions": "The Azure Developer CLI (azd) is either not installed or requires an update. The minimum required version that works with MCP tools is 1.20.0.\n\nTo install or upgrade, follow the instructions at https://aka.ms/azd/install\n\nAfter installation you may need to restart the Azure MCP server and your IDE." } } } diff --git a/core/Azure.Mcp.Core/src/Areas/Server/ServerJsonContext.cs b/core/Azure.Mcp.Core/src/Areas/Server/ServerJsonContext.cs index 1c86395d69..64f3b1f112 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/ServerJsonContext.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/ServerJsonContext.cs @@ -3,6 +3,8 @@ using System.Text.Json.Serialization; using Azure.Mcp.Core.Areas.Server.Models; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Models.Metadata; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -19,10 +21,13 @@ namespace Azure.Mcp.Core.Areas.Server; [JsonSerializable(typeof(List))] [JsonSerializable(typeof(ToolInputSchema))] [JsonSerializable(typeof(ToolPropertySchema))] +[JsonSerializable(typeof(ToolMetadata))] +[JsonSerializable(typeof(MetadataDefinition))] +[JsonSerializable(typeof(ConsolidatedToolDefinition))] +[JsonSerializable(typeof(List))] [JsonSourceGenerationOptions( PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, - DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull, - WriteIndented = true + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull )] internal sealed partial class ServerJsonContext : JsonSerializerContext { diff --git a/core/Azure.Mcp.Core/src/Areas/Server/ServerSetup.cs b/core/Azure.Mcp.Core/src/Areas/Server/ServerSetup.cs index 1c284c21ad..6dde7b57e0 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/ServerSetup.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/ServerSetup.cs @@ -14,6 +14,8 @@ public sealed class ServerSetup : IAreaSetup { public string Name => "server"; + public string Title => "MCP Server Management"; + /// /// Configures services required for the Server area. /// @@ -21,6 +23,7 @@ public sealed class ServerSetup : IAreaSetup public void ConfigureServices(IServiceCollection services) { services.AddSingleton(); + services.AddSingleton(); } /// @@ -31,12 +34,15 @@ public void ConfigureServices(IServiceCollection services) public CommandGroup RegisterCommands(IServiceProvider serviceProvider) { // Create MCP Server command group - var mcpServer = new CommandGroup(Name, "MCP Server operations - Commands for managing and interacting with the MCP Server."); + var mcpServer = new CommandGroup(Name, "MCP Server operations - Commands for managing and interacting with the MCP Server.", Title); // Register MCP Server commands var startCommand = serviceProvider.GetRequiredService(); mcpServer.AddCommand(startCommand.Name, startCommand); + var infoCommand = serviceProvider.GetRequiredService(); + mcpServer.AddCommand(infoCommand.Name, infoCommand); + return mcpServer; } } diff --git a/core/Azure.Mcp.Core/src/Areas/Subscription/Commands/SubscriptionListCommand.cs b/core/Azure.Mcp.Core/src/Areas/Subscription/Commands/SubscriptionListCommand.cs index 0cc746f1c3..2a4a464c06 100644 --- a/core/Azure.Mcp.Core/src/Areas/Subscription/Commands/SubscriptionListCommand.cs +++ b/core/Azure.Mcp.Core/src/Areas/Subscription/Commands/SubscriptionListCommand.cs @@ -15,14 +15,12 @@ public sealed class SubscriptionListCommand(ILogger log private const string CommandTitle = "List Azure Subscriptions"; private readonly ILogger _logger = logger; + public override string Id => "72bbe80e-ca42-4a43-8f02-45495bca1179"; + public override string Name => "list"; public override string Description => - $""" - List all Azure subscriptions accessible to your account. Optionally specify {OptionDefinitions.Common.TenantName} - and {OptionDefinitions.Common.AuthMethodName}. Results include subscription names and IDs, returned as a JSON array. - """; - + "List all or current subscriptions for an account in Azure; returns subscriptionId, displayName, state, tenantId, and isDefault. Use for scope selection in governance, policy, access, cost management, or deployment."; public override string Title => CommandTitle; public override ToolMetadata Metadata => new() @@ -35,7 +33,7 @@ List all Azure subscriptions accessible to your account. Optionally specify {Opt Secret = false }; - public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) { if (!Validate(parseResult.CommandResult, context.Response).IsValid) { @@ -47,7 +45,7 @@ public override async Task ExecuteAsync(CommandContext context, try { var subscriptionService = context.GetService(); - var subscriptions = await subscriptionService.GetSubscriptions(options.Tenant, options.RetryPolicy); + var subscriptions = await subscriptionService.GetSubscriptions(options.Tenant, options.RetryPolicy, cancellationToken); context.Response.Results = ResponseResult.Create( new SubscriptionListCommandResult(subscriptions), diff --git a/core/Azure.Mcp.Core/src/Areas/Subscription/SubscriptionSetup.cs b/core/Azure.Mcp.Core/src/Areas/Subscription/SubscriptionSetup.cs index 38168ce824..af505c50e4 100644 --- a/core/Azure.Mcp.Core/src/Areas/Subscription/SubscriptionSetup.cs +++ b/core/Azure.Mcp.Core/src/Areas/Subscription/SubscriptionSetup.cs @@ -11,6 +11,8 @@ public class SubscriptionSetup : IAreaSetup { public string Name => "subscription"; + public string Title => "Azure Subscriptions Management"; + public void ConfigureServices(IServiceCollection services) { services.AddSingleton(); @@ -20,7 +22,7 @@ public void ConfigureServices(IServiceCollection services) public CommandGroup RegisterCommands(IServiceProvider serviceProvider) { // Create Subscription command group - var subscription = new CommandGroup(Name, "Azure subscription operations - Commands for listing and managing Azure subscriptions accessible to your account."); + var subscription = new CommandGroup(Name, "Azure subscription operations - Commands for listing and managing Azure subscriptions accessible to your account.", Title); // Register Subscription commands var subscriptionListCommand = serviceProvider.GetRequiredService(); diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListCommand.cs b/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListCommand.cs index a1c8eb8612..a1e2ef4418 100644 --- a/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListCommand.cs +++ b/core/Azure.Mcp.Core/src/Areas/Tools/Commands/ToolsListCommand.cs @@ -1,24 +1,34 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.CommandLine.Parsing; +using System.Runtime.InteropServices; +using Azure.Mcp.Core.Areas.Tools.Options; using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Extensions; +using Azure.Mcp.Core.Models; +using Azure.Mcp.Core.Models.Command; using Azure.Mcp.Core.Models.Option; +using Microsoft.AspNetCore.Mvc.ModelBinding; using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Models.Option; namespace Azure.Mcp.Core.Areas.Tools.Commands; [HiddenCommand] -public sealed class ToolsListCommand(ILogger logger) : BaseCommand +public sealed class ToolsListCommand(ILogger logger) : BaseCommand { private const string CommandTitle = "List Available Tools"; + public override string Id => "63de05a7-047d-4f8a-86ea-cebd64527e2b"; + public override string Name => "list"; public override string Description => """ List all available commands and their tools in a hierarchical structure. This command returns detailed information about each command, including its name, description, full command path, available subcommands, and all supported - arguments. Use this to explore the CLI's functionality or to build interactive command interfaces. + arguments. Use --name-only to return only tool names, and --namespace to filter by specific namespaces. """; public override string Title => CommandTitle; @@ -33,29 +43,139 @@ arguments. Use this to explore the CLI's functionality or to build interactive c Secret = false }; - protected override EmptyOptions BindOptions(ParseResult parseResult) => new(); + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(ToolsListOptionDefinitions.NamespaceMode); + command.Options.Add(ToolsListOptionDefinitions.Namespace); + command.Options.Add(ToolsListOptionDefinitions.NameOnly); + } - public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult) + protected override ToolsListOptions BindOptions(ParseResult parseResult) + { + var namespaces = parseResult.GetValueOrDefault(ToolsListOptionDefinitions.Namespace.Name) ?? []; + return new ToolsListOptions + { + NamespaceMode = parseResult.GetValueOrDefault(ToolsListOptionDefinitions.NamespaceMode), + NameOnly = parseResult.GetValueOrDefault(ToolsListOptionDefinitions.NameOnly), + Namespaces = namespaces.ToList() + }; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) { try { var factory = context.GetService(); - var tools = await Task.Run(() => CommandFactory.GetVisibleCommands(factory.AllCommands) - .Select(kvp => CreateCommand(kvp.Key, kvp.Value)) - .ToList()); + var options = BindOptions(parseResult); + + // If the --namespace-mode flag is set, return distinct top‑level namespaces (e.g. child groups beneath root 'azmcp'). + if (options.NamespaceMode) + { + var ignored = new HashSet(StringComparer.OrdinalIgnoreCase) { "server", "tools" }; + var surfaced = new HashSet(StringComparer.OrdinalIgnoreCase) { "extension" }; + var rootGroup = factory.RootGroup; // azmcp + + var namespaceCommands = rootGroup.SubGroup + .Where(g => !ignored.Contains(g.Name) && !surfaced.Contains(g.Name)) + // Apply namespace filtering if specified + .Where(g => options.Namespaces.Count == 0 || options.Namespaces.Contains(g.Name, StringComparer.OrdinalIgnoreCase)) + .Select(g => new CommandInfo + { + Name = g.Name, + Description = g.Description ?? string.Empty, + Command = $"{g.Name}", + // We deliberately omit populating Subcommands for the lightweight namespace view. + }) + .OrderBy(ci => ci.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + + // Add the commands to be surfaced directly to the list. + // For commands in the surfaced list, each command is exposed as a separate tool in the namespace mode. + foreach (var name in surfaced) + { + // Apply namespace filtering for surfaced commands too + if (options.Namespaces.Count > 0 && !options.Namespaces.Contains(name, StringComparer.OrdinalIgnoreCase)) + continue; + + var subgroup = rootGroup.SubGroup.FirstOrDefault(g => string.Equals(g.Name, name, StringComparison.OrdinalIgnoreCase)); + if (subgroup is not null) + { + List foundCommands = []; + searchCommandInCommandGroup("", subgroup, foundCommands); + namespaceCommands.AddRange(foundCommands); + } + } + + // If --name-only is also specified, return only the names + if (options.NameOnly) + { + var namespaceNames = namespaceCommands.Select(nc => nc.Command).ToList(); + var result = new ToolNamesResult(namespaceNames); + context.Response.Results = ResponseResult.Create(result, ModelsJsonContext.Default.ToolNamesResult); + return context.Response; + } + + context.Response.Results = ResponseResult.Create(namespaceCommands, ModelsJsonContext.Default.ListCommandInfo); + return context.Response; + } + + // If the --name-only flag is set (without namespace mode), return only tool names + if (options.NameOnly) + { + // Get all visible commands and extract their tokenized names (full command paths) + var allToolNames = CommandFactory.GetVisibleCommands(factory.AllCommands) + .Select(kvp => kvp.Key) // Use the tokenized key instead of just the command name + .Where(name => !string.IsNullOrEmpty(name)); + + // Apply namespace filtering if specified (using underscore separator for tokenized names) + allToolNames = ApplyNamespaceFilterToNames(allToolNames, options.Namespaces, CommandFactory.Separator); + + var toolNames = await Task.Run(() => allToolNames + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToList()); + + var result = new ToolNamesResult(toolNames); + context.Response.Results = ResponseResult.Create(result, ModelsJsonContext.Default.ToolNamesResult); + return context.Response; + } + + // Get all tools with full details + var allTools = CommandFactory.GetVisibleCommands(factory.AllCommands) + .Select(kvp => CreateCommand(kvp.Key, kvp.Value)); + + // Apply namespace filtering if specified + var filteredToolNames = ApplyNamespaceFilterToNames(allTools.Select(t => t.Command), options.Namespaces, ' '); + var filteredToolNamesSet = filteredToolNames.ToHashSet(StringComparer.OrdinalIgnoreCase); + allTools = allTools.Where(tool => filteredToolNamesSet.Contains(tool.Command)); + + var tools = await Task.Run(() => allTools.ToList()); context.Response.Results = ResponseResult.Create(tools, ModelsJsonContext.Default.ListCommandInfo); return context.Response; } catch (Exception ex) { - logger.LogError(ex, "An exception occurred processing tool."); + logger.LogError(ex, "An exception occurred while processing tool listing."); HandleException(context, ex); return context.Response; } } + private static IEnumerable ApplyNamespaceFilterToNames(IEnumerable names, List namespaces, char separator) + { + if (namespaces.Count == 0) + { + return names; + } + + var namespacePrefixes = namespaces.Select(ns => $"{ns}{separator}").ToList(); + + return names.Where(name => + namespacePrefixes.Any(prefix => name.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))); + } + private static CommandInfo CreateCommand(string tokenizedName, IBaseCommand command) { var commandDetails = command.GetCommand(); @@ -67,12 +187,37 @@ private static CommandInfo CreateCommand(string tokenizedName, IBaseCommand comm required: arg.Required)) .ToList(); + var fullCommand = tokenizedName.Replace(CommandFactory.Separator, ' '); + return new CommandInfo { + Id = command.Id, Name = commandDetails.Name, Description = commandDetails.Description ?? string.Empty, - Command = tokenizedName.Replace(CommandFactory.Separator, ' '), + Command = fullCommand, Options = optionInfos, + Metadata = command.Metadata }; } + + public record ToolNamesResult(List Names); + private void searchCommandInCommandGroup(string commandPrefix, CommandGroup searchedGroup, List foundCommands) + { + var commands = CommandFactory.GetVisibleCommands(searchedGroup.Commands).Select(kvp => + { + var command = kvp.Value.GetCommand(); + return new CommandInfo + { + Name = $"{commandPrefix.Replace(" ", "_")}{searchedGroup.Name}_{command.Name}", + Description = command.Description ?? string.Empty, + Command = $"{(!string.IsNullOrEmpty(commandPrefix) ? commandPrefix : "")}{searchedGroup.Name} {command.Name}" + // Omit Options and Subcommands for surfaced commands as well. + }; + }); + foundCommands.AddRange(commands); + foreach (CommandGroup nextLevelSubGroup in searchedGroup.SubGroup) + { + searchCommandInCommandGroup($"{commandPrefix}{searchedGroup.Name} ", nextLevelSubGroup, foundCommands); + } + } } diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptionDefinitions.cs b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptionDefinitions.cs new file mode 100644 index 0000000000..8452505125 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptionDefinitions.cs @@ -0,0 +1,30 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Areas.Tools.Options; + +public static class ToolsListOptionDefinitions +{ + public const string NamespaceModeOptionName = "namespace-mode"; + public const string NamespaceOptionName = "namespace"; + public const string NameOnlyOptionName = "name-only"; + + public static readonly Option NamespaceMode = new($"--{NamespaceModeOptionName}") + { + Description = "If specified, returns a list of top-level service namespaces instead of individual tools.", + Required = false + }; + + public static readonly Option Namespace = new($"--{NamespaceOptionName}") + { + Description = "Filter tools by namespace (e.g., 'storage', 'keyvault'). Can be specified multiple times to include multiple namespaces.", + Required = false, + AllowMultipleArgumentsPerToken = true + }; + + public static readonly Option NameOnly = new($"--{NameOnlyOptionName}") + { + Description = "If specified, returns only tool names without descriptions or metadata.", + Required = false + }; +} diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptions.cs b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptions.cs new file mode 100644 index 0000000000..db268bd9f5 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Tools/Options/ToolsListOptions.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Areas.Tools.Options; + +public sealed class ToolsListOptions +{ + public bool NamespaceMode { get; set; } = false; + + /// + /// If true, returns only tool names without descriptions or metadata. + /// + public bool NameOnly { get; set; } = false; + + /// + /// Optional namespaces to filter tools. If provided, only tools from these namespaces will be returned. + /// + public List Namespaces { get; set; } = new(); +} diff --git a/core/Azure.Mcp.Core/src/Areas/Tools/ToolsSetup.cs b/core/Azure.Mcp.Core/src/Areas/Tools/ToolsSetup.cs index 10fc821a59..3e5b6f58d6 100644 --- a/core/Azure.Mcp.Core/src/Areas/Tools/ToolsSetup.cs +++ b/core/Azure.Mcp.Core/src/Areas/Tools/ToolsSetup.cs @@ -11,6 +11,8 @@ public sealed class ToolsSetup : IAreaSetup { public string Name => "tools"; + public string Title => "MCP Tools Discovery"; + public void ConfigureServices(IServiceCollection services) { services.AddSingleton(); @@ -19,7 +21,7 @@ public void ConfigureServices(IServiceCollection services) public CommandGroup RegisterCommands(IServiceProvider serviceProvider) { // Create Tools command group - var tools = new CommandGroup(Name, "CLI tools operations - Commands for discovering and exploring the functionality available in this CLI tool."); + var tools = new CommandGroup(Name, "CLI tools operations - Commands for discovering and exploring the functionality available in this CLI tool.", Title); var list = serviceProvider.GetRequiredService(); tools.AddCommand(list.Name, list); diff --git a/core/Azure.Mcp.Core/src/Azure.Mcp.Core.csproj b/core/Azure.Mcp.Core/src/Azure.Mcp.Core.csproj index d5d0114d0a..53ed202815 100644 --- a/core/Azure.Mcp.Core/src/Azure.Mcp.Core.csproj +++ b/core/Azure.Mcp.Core/src/Azure.Mcp.Core.csproj @@ -1,6 +1,8 @@ true + + true true @@ -21,7 +23,10 @@ + + + - + diff --git a/core/Azure.Mcp.Core/src/Commands/BaseCommand.cs b/core/Azure.Mcp.Core/src/Commands/BaseCommand.cs index b5c5ae7fbf..4e159599a1 100644 --- a/core/Azure.Mcp.Core/src/Commands/BaseCommand.cs +++ b/core/Azure.Mcp.Core/src/Commands/BaseCommand.cs @@ -24,7 +24,7 @@ protected BaseCommand() } public Command GetCommand() => _command; - + public abstract string Id { get; } public abstract string Name { get; } public abstract string Description { get; } public abstract string Title { get; } @@ -42,7 +42,7 @@ protected virtual void RegisterOptions(Command command) /// An options object containing the bound options. protected abstract TOptions BindOptions(ParseResult parseResult); - public abstract Task ExecuteAsync(CommandContext context, ParseResult parseResult); + public abstract Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken); protected virtual void HandleException(CommandContext context, Exception ex) { @@ -90,9 +90,9 @@ protected virtual void HandleException(CommandContext context, Exception ex) _ => HttpStatusCode.InternalServerError // Internal Server Error for unexpected errors }; - public virtual ValidationResult Validate(CommandResult commandResult, CommandResponse? commandResponse = null) + public ValidationResult Validate(CommandResult commandResult, CommandResponse? commandResponse = null) { - var result = new ValidationResult { IsValid = true }; + var result = new ValidationResult(); // First, check for missing required options var missingOptions = commandResult.Command.Options @@ -104,32 +104,22 @@ public virtual ValidationResult Validate(CommandResult commandResult, CommandRes if (!string.IsNullOrEmpty(missingOptionsJoined)) { - result.IsValid = false; - result.ErrorMessage = $"{MissingRequiredOptionsPrefix}{missingOptionsJoined}"; - SetValidationError(commandResponse, result.ErrorMessage!); - return result; + result.Errors.Add($"{MissingRequiredOptionsPrefix}{missingOptionsJoined}"); } // Check for parser/validator errors if (commandResult.Errors != null && commandResult.Errors.Any()) { - result.IsValid = false; - var combined = string.Join(", ", commandResult.Errors.Select(e => e.Message)); - result.ErrorMessage = combined; - SetValidationError(commandResponse, result.ErrorMessage); - return result; + result.Errors.Add(string.Join(", ", commandResult.Errors.Select(e => e.Message))); } - return result; - - static void SetValidationError(CommandResponse? response, string errorMessage) + if (!result.IsValid && commandResponse != null) { - if (response != null) - { - response.Status = HttpStatusCode.BadRequest; - response.Message = errorMessage; - } + commandResponse.Status = HttpStatusCode.BadRequest; + commandResponse.Message = string.Join('\n', result.Errors); } + + return result; } /// diff --git a/core/Azure.Mcp.Core/src/Commands/CommandFactory.cs b/core/Azure.Mcp.Core/src/Commands/CommandFactory.cs index ba34d7a671..7396335852 100644 --- a/core/Azure.Mcp.Core/src/Commands/CommandFactory.cs +++ b/core/Azure.Mcp.Core/src/Commands/CommandFactory.cs @@ -8,8 +8,10 @@ using System.Text.Encodings.Web; using System.Text.Json.Serialization; using Azure.Mcp.Core.Areas; +using Azure.Mcp.Core.Configuration; using Azure.Mcp.Core.Services.Telemetry; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants; namespace Azure.Mcp.Core.Commands; @@ -23,7 +25,6 @@ public class CommandFactory private readonly CommandGroup _rootGroup; private readonly ModelsJsonContext _srcGenWithOptions; - private const string RootCommandGroupName = "azmcp"; public const char Separator = '_'; /// @@ -32,6 +33,7 @@ public class CommandFactory private readonly Dictionary _commandMap; private readonly Dictionary _commandNamesToArea = new(StringComparer.OrdinalIgnoreCase); private readonly ITelemetryService _telemetryService; + private readonly IOptions _configurationOptions; // Add this new class inside CommandFactory private class StringConverter : JsonConverter @@ -48,15 +50,21 @@ public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOp } } - public CommandFactory(IServiceProvider serviceProvider, IEnumerable serviceAreas, ITelemetryService telemetryService, ILogger logger) + + public CommandFactory(IServiceProvider serviceProvider, + IEnumerable serviceAreas, + ITelemetryService telemetryService, + IOptions configurationOptions, + ILogger logger) { _serviceAreas = serviceAreas?.ToArray() ?? throw new ArgumentNullException(nameof(serviceAreas)); _serviceProvider = serviceProvider; _logger = logger; - _rootGroup = new CommandGroup(RootCommandGroupName, "Azure MCP Server"); - _rootCommand = CreateRootCommand(); - _commandMap = CreateCommandDictionary(_rootGroup, string.Empty); _telemetryService = telemetryService; + _configurationOptions = configurationOptions; + _rootGroup = new CommandGroup(_configurationOptions.Value.RootCommandGroupName, _configurationOptions.Value.DisplayName); + _rootCommand = CreateRootCommand(); + _commandMap = CreateCommandDictionary(_rootGroup); _srcGenWithOptions = new ModelsJsonContext(new JsonSerializerOptions { WriteIndented = true, @@ -85,7 +93,7 @@ public IReadOnlyDictionary GroupCommands(string[] groupNam { if (string.Equals(group.Name, groupName, StringComparison.OrdinalIgnoreCase)) { - var commandsInGroup = CreateCommandDictionary(group, string.Empty); + var commandsInGroup = CreateCommandDictionaryInner(group, string.Empty); foreach (var (key, value) in commandsInGroup) { commandsFromGroups[key] = value; @@ -135,10 +143,10 @@ private void RegisterCommandGroup() // Create a temporary root node to register all the area's subgroups and commands to. // Use this to create the mapping of all commands to that area. - var tempRoot = new CommandGroup(RootCommandGroupName, string.Empty); + var tempRoot = new CommandGroup(_rootGroup.Name, string.Empty); tempRoot.AddSubGroup(commandTree); - var commandDictionary = CreateCommandDictionary(tempRoot, string.Empty); + var commandDictionary = CreateCommandDictionary(tempRoot); if (_logger.IsEnabled(LogLevel.Debug)) { @@ -174,12 +182,12 @@ private void ConfigureCommands(CommandGroup group) private void ConfigureCommandHandler(Command command, IBaseCommand implementation) { - command.SetAction(async (ParseResult parseResult, CancellationToken ct) => + command.SetAction(async (parseResult, ct) => { _logger.LogTrace("Executing '{Command}'.", command.Name); - using var activity = await _telemetryService.StartActivity(ActivityName.CommandExecuted); - + using var activity = _telemetryService.StartActivity(ActivityName.CommandExecuted); + activity?.SetTag(TagName.ToolId, implementation.Id); var cmdContext = new CommandContext(_serviceProvider, activity); var startTime = DateTime.UtcNow; try @@ -192,7 +200,7 @@ private void ConfigureCommandHandler(Command command, IBaseCommand implementatio return (int)cmdContext.Response.Status; } - var response = await implementation.ExecuteAsync(cmdContext, parseResult); + var response = await implementation.ExecuteAsync(cmdContext, parseResult, ct); // Calculate execution time var endTime = DateTime.UtcNow; @@ -235,6 +243,8 @@ private RootCommand CreateRootCommand() // RootCommand title/description comes from the root group var root = new RootCommand(_rootGroup.Description); + CustomizeHelpOption(root); + // Register area groups and their commands RegisterCommandGroup(); @@ -244,11 +254,25 @@ private RootCommand CreateRootCommand() ConfigureCommands(subGroup); root.Subcommands.Add(subGroup.Command); subGroup.Command.Options.Add(new HelpOption()); + + CustomizeHelpOption(subGroup.Command); } return root; } + private void CustomizeHelpOption(Command command) + { + for (int i = 0; i < command.Options.Count; i++) + { + if (command.Options[i] is HelpOption helpOption && helpOption.Action is HelpAction helpAction) + { + helpOption.Action = new VersionDisplayHelpAction(_configurationOptions, helpAction); + break; + } + } + } + private static IBaseCommand? FindCommandInGroup(CommandGroup group, Queue nameParts) { // If we've processed all parts and this group has a matching command, return it @@ -276,7 +300,7 @@ private RootCommand CreateRootCommand() } /// - /// Gets the service area given the full command name (i.e. 'storage_account_list' or 'azmcp_storage_account_list' would return 'storage'). + /// Gets the service area given the full command name (i.e. 'storage_account_list' would return 'storage'). /// /// Name of the command. public string? GetServiceArea(string fullCommandName) @@ -290,22 +314,80 @@ private RootCommand CreateRootCommand() { return area.Name; } - - // If it starts with azmcp, then it is already the full command name. - if (fullCommandName.StartsWith(RootCommandGroupName, StringComparison.OrdinalIgnoreCase)) + else { return null; } + } + + /// + /// Creates a command dictionary. Each sibling and child of the root node is created without using its name as a prefix. + /// + /// Node: RootNode + /// * Siblings: A11, A12 + /// * Children (Subgroups): B1, B2 + /// + /// Node: B1 + /// * Siblings: B11 + /// * Children: C1, C2 + /// + /// The command dictionary would be output: + /// - A11 + /// - A12 + /// - B1_B11 + /// - B1_C1 + /// - B1_C2 + /// - B2 + /// + /// Node to begin traversal. + internal static Dictionary CreateCommandDictionary(CommandGroup rootNode) + { + const string rootPrefix = ""; + var aggregated = new Dictionary(); + + // Add any immediate commands from root group. + foreach (var kvp in rootNode.Commands) + { + aggregated.Add(kvp.Key, kvp.Value); + } + + // Add any sub commands. + foreach (var command in rootNode.SubGroup) + { + var temp = CreateCommandDictionaryInner(command, rootPrefix); + + foreach (var kvp in temp) + { + aggregated.Add(kvp.Key, kvp.Value); + } + } - // Else, it means that the command could be from namespace mode where the IAreaSetup.Name - // is the root of the command tree. - var rootPrefixAppended = string.Join(Separator, RootCommandGroupName, fullCommandName); - return _commandNamesToArea.TryGetValue(rootPrefixAppended, out var area2) - ? area2.Name - : null; + return aggregated; } - internal static Dictionary CreateCommandDictionary(CommandGroup node, string prefix) + /// + /// Creates a command dictionary. Each direct node and descendent is created with its parent's name as + /// its first prefix. For example, given the tree: + /// + /// Node: A1 + /// * Siblings: A11, A12 + /// * Children (Subgroups): B1, B2 + /// + /// Node: B1 + /// * Siblings: B11 + /// * Children: C1, C2 + /// + /// The command dictionary would be output: + /// - A1_A11 + /// - A1_A12 + /// - A1_B1_B11 + /// - A1_B1_C1 + /// - A1_B1_C2 + /// - A1_B2 + /// + /// Node to begin traversal. + /// Prefix. If prefix is an empty string, the name of the current node is used. + internal static Dictionary CreateCommandDictionaryInner(CommandGroup node, string prefix) { var aggregated = new Dictionary(); var updatedPrefix = GetPrefix(prefix, node.Name); @@ -326,7 +408,7 @@ internal static Dictionary CreateCommandDictionary(Command foreach (var command in node.SubGroup) { - var subcommandsDictionary = CreateCommandDictionary(command, updatedPrefix); + var subcommandsDictionary = CreateCommandDictionaryInner(command, updatedPrefix); foreach (var item in subcommandsDictionary) { aggregated.Add(item.Key, item.Value); diff --git a/core/Azure.Mcp.Core/src/Commands/CommandGroup.cs b/core/Azure.Mcp.Core/src/Commands/CommandGroup.cs index 0e323d343f..c76f61c2d0 100644 --- a/core/Azure.Mcp.Core/src/Commands/CommandGroup.cs +++ b/core/Azure.Mcp.Core/src/Commands/CommandGroup.cs @@ -3,13 +3,15 @@ namespace Azure.Mcp.Core.Commands; -public class CommandGroup(string name, string description) +public class CommandGroup(string name, string description, string? title = null) { public string Name { get; } = name; public string Description { get; } = description; + public string? Title { get; } = title; public List SubGroup { get; } = []; public Dictionary Commands { get; } = []; public Command Command { get; } = new Command(name, description); + public ToolMetadata? ToolMetadata { get; set; } public void AddCommand(string path, IBaseCommand command) { diff --git a/core/Azure.Mcp.Core/src/Commands/GlobalCommand.cs b/core/Azure.Mcp.Core/src/Commands/GlobalCommand.cs index 8605a8550c..c674fa0781 100644 --- a/core/Azure.Mcp.Core/src/Commands/GlobalCommand.cs +++ b/core/Azure.Mcp.Core/src/Commands/GlobalCommand.cs @@ -8,8 +8,6 @@ using Azure.Mcp.Core.Models.Option; using Azure.Mcp.Core.Options; -#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously - namespace Azure.Mcp.Core.Commands; public abstract class GlobalCommand< @@ -82,31 +80,20 @@ protected override TOptions BindOptions(ParseResult parseResult) { var policy = new RetryPolicyOptions(); - if (parseResult.GetResult(OptionDefinitions.RetryPolicy.MaxRetries) != null) - { - policy.HasMaxRetries = true; - policy.MaxRetries = parseResult.GetValueOrDefault(OptionDefinitions.RetryPolicy.MaxRetries.Name); - } - if (parseResult.GetResult(OptionDefinitions.RetryPolicy.Delay) != null) - { - policy.HasDelaySeconds = true; - policy.DelaySeconds = parseResult.GetValueOrDefault(OptionDefinitions.RetryPolicy.Delay.Name); - } - if (parseResult.GetResult(OptionDefinitions.RetryPolicy.MaxDelay) != null) - { - policy.HasMaxDelaySeconds = true; - policy.MaxDelaySeconds = parseResult.GetValueOrDefault(OptionDefinitions.RetryPolicy.MaxDelay.Name); - } - if (parseResult.GetResult(OptionDefinitions.RetryPolicy.Mode) != null) - { - policy.HasMode = true; - policy.Mode = parseResult.GetValueOrDefault(OptionDefinitions.RetryPolicy.Mode.Name); - } - if (parseResult.GetResult(OptionDefinitions.RetryPolicy.NetworkTimeout) != null) - { - policy.HasNetworkTimeoutSeconds = true; - policy.NetworkTimeoutSeconds = parseResult.GetValueOrDefault(OptionDefinitions.RetryPolicy.NetworkTimeout.Name); - } + policy.HasMaxRetries = parseResult.TryGetValue(OptionDefinitions.RetryPolicy.MaxRetries.Name, out int maxRetries); + policy.MaxRetries = maxRetries; + + policy.HasDelaySeconds = parseResult.TryGetValue(OptionDefinitions.RetryPolicy.Delay.Name, out double delaySeconds); + policy.DelaySeconds = delaySeconds; + + policy.HasMaxDelaySeconds = parseResult.TryGetValue(OptionDefinitions.RetryPolicy.MaxDelay.Name, out double maxDelaySeconds); + policy.MaxDelaySeconds = maxDelaySeconds; + + policy.HasMode = parseResult.TryGetValue(OptionDefinitions.RetryPolicy.Mode.Name, out RetryMode mode); + policy.Mode = mode; + + policy.HasNetworkTimeoutSeconds = parseResult.TryGetValue(OptionDefinitions.RetryPolicy.NetworkTimeout.Name, out double networkTimeoutSeconds); + policy.NetworkTimeoutSeconds = networkTimeoutSeconds; // Only assign if at least one flag set (defensive) if (policy.HasMaxRetries || policy.HasDelaySeconds || policy.HasMaxDelaySeconds || policy.HasMode || policy.HasNetworkTimeoutSeconds) diff --git a/core/Azure.Mcp.Core/src/Commands/IBaseCommand.cs b/core/Azure.Mcp.Core/src/Commands/IBaseCommand.cs index f61555dcdb..7020be8f75 100644 --- a/core/Azure.Mcp.Core/src/Commands/IBaseCommand.cs +++ b/core/Azure.Mcp.Core/src/Commands/IBaseCommand.cs @@ -12,6 +12,12 @@ namespace Azure.Mcp.Core.Commands; [DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicMethods)] public interface IBaseCommand { + /// + /// A unique identifier for the command. Identifier must be a constant value representing a GUID. + /// See to generate a random GUID. + /// + string Id { get; } + /// /// Gets the name of the command /// @@ -41,7 +47,7 @@ public interface IBaseCommand /// /// Executes the command /// - Task ExecuteAsync(CommandContext context, ParseResult parseResult); + Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken); ValidationResult Validate(CommandResult commandResult, CommandResponse? commandResponse = null); } diff --git a/core/Azure.Mcp.Core/src/Commands/Subscription/SubscriptionCommand.cs b/core/Azure.Mcp.Core/src/Commands/Subscription/SubscriptionCommand.cs index eb4ef99d2e..f54b0ed857 100644 --- a/core/Azure.Mcp.Core/src/Commands/Subscription/SubscriptionCommand.cs +++ b/core/Azure.Mcp.Core/src/Commands/Subscription/SubscriptionCommand.cs @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.CommandLine.Parsing; using System.Diagnostics.CodeAnalysis; using Azure.Mcp.Core.Helpers; using Azure.Mcp.Core.Models.Option; @@ -17,12 +16,11 @@ protected override void RegisterOptions(Command command) { base.RegisterOptions(command); command.Options.Add(OptionDefinitions.Common.Subscription); - - // Command-level validation for presence: allow either --subscription or AZURE_SUBSCRIPTION_ID - // This mirrors the prior behavior that preferred the explicit option but fell back to env var. command.Validators.Add(commandResult => { - if (!HasSubscriptionAvailable(commandResult)) + // Command-level validation for presence: allow either --subscription or AZURE_SUBSCRIPTION_ID + // This mirrors the prior behavior that preferred the explicit option but fell back to env var. + if (!CommandHelper.HasSubscriptionAvailable(commandResult)) { commandResult.AddError("Missing Required options: --subscription"); } @@ -32,32 +30,13 @@ protected override void RegisterOptions(Command command) protected override TOptions BindOptions(ParseResult parseResult) { var options = base.BindOptions(parseResult); - - // Get subscription from command line option or fallback to environment variable - var subscriptionValue = parseResult.GetValueOrDefault(OptionDefinitions.Common.Subscription.Name); - - var envSubscription = EnvironmentHelpers.GetAzureSubscriptionId(); - options.Subscription = (string.IsNullOrEmpty(subscriptionValue) - || IsPlaceholder(subscriptionValue)) - && !string.IsNullOrEmpty(envSubscription) - ? envSubscription - : subscriptionValue; + options.Subscription = CommandHelper.GetSubscription(parseResult); + if (!string.IsNullOrEmpty(options.Subscription)) + { + // Trim any surrounding quotes that may have been included in the input + options.Subscription = options.Subscription.Trim('"', '\''); + } return options; } - - /// - /// Checks if a subscription is available either from the command option or AZURE_SUBSCRIPTION_ID environment variable. - /// - /// The command result to check for the subscription option. - /// True if a subscription is available, false otherwise. - protected static bool HasSubscriptionAvailable(CommandResult commandResult) - { - var hasOption = commandResult.HasOptionResult(OptionDefinitions.Common.Subscription); - var hasEnv = !string.IsNullOrEmpty(EnvironmentHelpers.GetAzureSubscriptionId()); - return hasOption || hasEnv; - } - - private static bool IsPlaceholder(string value) - => value.Contains("subscription") || value.Contains("default"); } diff --git a/core/Azure.Mcp.Core/src/Commands/ToolMetadata.cs b/core/Azure.Mcp.Core/src/Commands/ToolMetadata.cs index c046ac7c6e..2cba4e9452 100644 --- a/core/Azure.Mcp.Core/src/Commands/ToolMetadata.cs +++ b/core/Azure.Mcp.Core/src/Commands/ToolMetadata.cs @@ -1,14 +1,31 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Text.Json.Serialization; +using Azure.Mcp.Core.Models.Metadata; + namespace Azure.Mcp.Core.Commands; /// /// Provides metadata about an MCP tool describing its behavioral characteristics. /// This metadata helps MCP clients understand how the tool operates and its potential effects. /// +[JsonConverter(typeof(ToolMetadataConverter))] public sealed class ToolMetadata { + private bool _destructive = true; + private bool _idempotent = false; + private bool _openWorld = true; + private bool _readOnly = false; + private bool _secret = false; + private bool _localRequired = false; + + private MetadataDefinition? _destructiveProperty; + private MetadataDefinition? _idempotentProperty; + private MetadataDefinition? _openWorldProperty; + private MetadataDefinition? _readOnlyProperty; + private MetadataDefinition? _secretProperty; + private MetadataDefinition? _localRequiredProperty; /// /// Gets or sets whether the tool may perform destructive updates to its environment. /// @@ -22,7 +39,22 @@ public sealed class ToolMetadata /// The default is . /// /// - public bool Destructive { get; init; } = true; + [JsonIgnore] + public bool Destructive + { + get => _destructive; + init => _destructive = value; + } + + + [JsonPropertyName("destructive")] + public MetadataDefinition DestructiveProperty => _destructiveProperty ??= new MetadataDefinition + { + Value = _destructive, + Description = _destructive + ? "This tool may delete or modify existing resources in its environment." + : "This tool performs only additive updates without deleting or modifying existing resources." + }; /// /// Gets or sets whether calling the tool repeatedly with the same arguments @@ -36,7 +68,21 @@ public sealed class ToolMetadata /// The default is . /// /// - public bool Idempotent { get; init; } = false; + [JsonIgnore] + public bool Idempotent + { + get => _idempotent; + init => _idempotent = value; + } + + [JsonPropertyName("idempotent")] + public MetadataDefinition IdempotentProperty => _idempotentProperty ??= new MetadataDefinition + { + Value = _idempotent, + Description = _idempotent + ? "Running this operation multiple times with the same arguments produces the same result without additional effects." + : "Running this operation multiple times with the same arguments may have additional effects or produce different results." + }; /// /// Gets or sets whether this tool may interact with an "open world" of external entities. @@ -50,7 +96,21 @@ public sealed class ToolMetadata /// The default is . /// /// - public bool OpenWorld { get; init; } = true; + [JsonIgnore] + public bool OpenWorld + { + get => _openWorld; + init => _openWorld = value; + } + + [JsonPropertyName("openWorld")] + public MetadataDefinition OpenWorldProperty => _openWorldProperty ??= new MetadataDefinition + { + Value = _openWorld, + Description = _openWorld + ? "This tool may interact with an unpredictable or dynamic set of entities (like web search)." + : "This tool's domain of interaction is closed and well-defined, limited to a specific set of entities (like memory access)." + }; /// /// Gets or sets whether this tool does not modify its environment. @@ -68,7 +128,21 @@ public sealed class ToolMetadata /// The default is . /// /// - public bool ReadOnly { get; init; } = false; + [JsonIgnore] + public bool ReadOnly + { + get => _readOnly; + init => _readOnly = value; + } + + [JsonPropertyName("readOnly")] + public MetadataDefinition ReadOnlyProperty => _readOnlyProperty ??= new MetadataDefinition + { + Value = _readOnly, + Description = _readOnly + ? "This tool only performs read operations without modifying any state or data." + : "This tool may modify its environment and perform write operations (create, update, delete)." + }; /// /// Gets or sets whether this tool deals with sensitive or secret information. @@ -86,7 +160,21 @@ public sealed class ToolMetadata /// The default is . /// /// - public bool Secret { get; init; } = false; + [JsonIgnore] + public bool Secret + { + get => _secret; + init => _secret = value; + } + + [JsonPropertyName("secret")] + public MetadataDefinition SecretProperty => _secretProperty ??= new MetadataDefinition + { + Value = _secret, + Description = _secret + ? "This tool handles sensitive data such as secrets, credentials, keys, or other confidential information." + : "This tool does not handle sensitive or secret information." + }; /// /// Gets or sets whether this tool requires local execution or resources. @@ -104,7 +192,24 @@ public sealed class ToolMetadata /// The default is . /// /// - public bool LocalRequired { get; init; } = false; + [JsonIgnore] + public bool LocalRequired + { + get => _localRequired; + init => _localRequired = value; + } + + /// + /// Gets the localRequired metadata property with value and description for serialization. + /// + [JsonPropertyName("localRequired")] + public MetadataDefinition LocalRequiredProperty => _localRequiredProperty ??= new MetadataDefinition + { + Value = _localRequired, + Description = _localRequired + ? "This tool is only available when the Azure MCP server is configured to run as a Local MCP Server (STDIO)." + : "This tool is available in both local and remote server modes." + }; /// /// Creates a new instance of with default values. @@ -113,4 +218,22 @@ public sealed class ToolMetadata public ToolMetadata() { } + + [JsonConstructor] + public ToolMetadata( + MetadataDefinition destructive, + MetadataDefinition idempotent, + MetadataDefinition openWorld, + MetadataDefinition readOnly, + MetadataDefinition secret, + MetadataDefinition localRequired) + { + _destructive = destructive?.Value ?? true; + _idempotent = idempotent?.Value ?? false; + _openWorld = openWorld?.Value ?? true; + _readOnly = readOnly?.Value ?? false; + _secret = secret?.Value ?? false; + _localRequired = localRequired?.Value ?? false; + } + } diff --git a/core/Azure.Mcp.Core/src/Commands/ToolMetadataJsonConverter.cs b/core/Azure.Mcp.Core/src/Commands/ToolMetadataJsonConverter.cs new file mode 100644 index 0000000000..a87ffe4541 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Commands/ToolMetadataJsonConverter.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Azure.Mcp.Core.Areas.Server; +using Azure.Mcp.Core.Models.Metadata; + +namespace Azure.Mcp.Core.Commands; + +/// +/// Custom JSON converter for that handles serialization and deserialization +/// of metadata properties with nested value and description objects. +/// +public sealed class ToolMetadataConverter : JsonConverter +{ + public override ToolMetadata Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + using var jsonDoc = JsonDocument.ParseValue(ref reader); + var root = jsonDoc.RootElement; + + MetadataDefinition GetMetadata(string name, bool defaultValue) + { + if (!root.TryGetProperty(name, out var prop)) + return new MetadataDefinition { Value = defaultValue, Description = string.Empty }; + + var meta = JsonSerializer.Deserialize(prop.GetRawText(), ServerJsonContext.Default.MetadataDefinition) + ?? new MetadataDefinition { Value = defaultValue, Description = string.Empty }; + return meta; + } + return new ToolMetadata( + GetMetadata("destructive", true), + GetMetadata("idempotent", false), + GetMetadata("openWorld", true), + GetMetadata("readOnly", false), + GetMetadata("secret", false), + GetMetadata("localRequired", false) + ); + } + + public override void Write(Utf8JsonWriter writer, ToolMetadata value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + void WriteMetadata(string name, MetadataDefinition def) + { + writer.WritePropertyName(name); + JsonSerializer.Serialize(writer, def, ServerJsonContext.Default.MetadataDefinition); + } + + WriteMetadata("destructive", value.DestructiveProperty); + WriteMetadata("idempotent", value.IdempotentProperty); + WriteMetadata("openWorld", value.OpenWorldProperty); + WriteMetadata("readOnly", value.ReadOnlyProperty); + WriteMetadata("secret", value.SecretProperty); + WriteMetadata("localRequired", value.LocalRequiredProperty); + + writer.WriteEndObject(); + } + +} diff --git a/core/Azure.Mcp.Core/src/Commands/ValidationResult.cs b/core/Azure.Mcp.Core/src/Commands/ValidationResult.cs index 69a3ad1573..897519b3f1 100644 --- a/core/Azure.Mcp.Core/src/Commands/ValidationResult.cs +++ b/core/Azure.Mcp.Core/src/Commands/ValidationResult.cs @@ -5,6 +5,7 @@ namespace Azure.Mcp.Core.Commands; public class ValidationResult { - public bool IsValid { get; set; } - public string? ErrorMessage { get; set; } + public bool IsValid => Errors.Count == 0; + + public List Errors { get; } = []; } diff --git a/core/Azure.Mcp.Core/src/Commands/VersionDisplayHelpAction.cs b/core/Azure.Mcp.Core/src/Commands/VersionDisplayHelpAction.cs new file mode 100644 index 0000000000..5af9d03672 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Commands/VersionDisplayHelpAction.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine.Help; +using System.CommandLine.Invocation; +using Azure.Mcp.Core.Configuration; +using Microsoft.Extensions.Options; + +namespace Azure.Mcp.Core.Commands; + +/// +/// Custom help action that displays version information before the standard help output. +/// +internal class VersionDisplayHelpAction : SynchronousCommandLineAction +{ + private readonly IOptions _options; + private readonly HelpAction _defaultHelp; + + public VersionDisplayHelpAction(IOptions options, HelpAction action) + { + _options = options; + _defaultHelp = action; + } + + public override int Invoke(ParseResult parseResult) + { + Console.WriteLine($"{_options.Value.Name} {_options.Value.Version}{Environment.NewLine}"); + + return _defaultHelp.Invoke(parseResult); + } +} diff --git a/core/Azure.Mcp.Core/src/Configuration/AzureMcpServerConfiguration.cs b/core/Azure.Mcp.Core/src/Configuration/AzureMcpServerConfiguration.cs index aa2c922a33..7f0d53ace6 100644 --- a/core/Azure.Mcp.Core/src/Configuration/AzureMcpServerConfiguration.cs +++ b/core/Azure.Mcp.Core/src/Configuration/AzureMcpServerConfiguration.cs @@ -3,13 +3,33 @@ namespace Azure.Mcp.Core.Configuration; +/// +/// Configuration settings for the MCP server. +/// public class AzureMcpServerConfiguration { - public const string DefaultName = "Azure.Mcp.Server"; + /// + /// The default prefix for the MCP server commands and help menus. + /// + public required string RootCommandGroupName { get; set; } - public string Name { get; set; } = DefaultName; + /// + /// The name of the MCP server. (i.e. Azure.Mcp.Server) + /// + public required string Name { get; set; } - public string Version { get; set; } = "1.0.0-beta"; + /// + /// The display name of the MCP server. + /// + public required string DisplayName { get; set; } + /// + /// The version of the MCP server. + /// + public required string Version { get; set; } + + /// + /// Indicates whether telemetry is enabled for the MCP server. By default, it is set to true. + /// public bool IsTelemetryEnabled { get; set; } = true; } diff --git a/core/Azure.Mcp.Core/src/Extensions/CommandResultExtensions.cs b/core/Azure.Mcp.Core/src/Extensions/CommandResultExtensions.cs index 6a4f840f33..f72ff516c8 100644 --- a/core/Azure.Mcp.Core/src/Extensions/CommandResultExtensions.cs +++ b/core/Azure.Mcp.Core/src/Extensions/CommandResultExtensions.cs @@ -7,9 +7,6 @@ namespace Azure.Mcp.Core.Extensions; public static class CommandResultExtensions { - public static SymbolResult? GetOptionResult(this CommandResult commandResult, Option option) - => commandResult.GetResult(option); - public static bool HasOptionResult(this CommandResult commandResult, Option option) { var result = commandResult.GetResult(option); @@ -81,7 +78,7 @@ public static bool TryGetValue(this CommandResult commandResult, Option op // Handle nullable types explicitly - null is a valid value for nullable types if (def is null && typeof(T).IsGenericType && typeof(T).GetGenericTypeDefinition() == typeof(Nullable<>)) { - value = default(T); // This will be null for nullable types + value = default; // This will be null for nullable types return true; } if (def is T typed) @@ -95,12 +92,24 @@ public static bool TryGetValue(this CommandResult commandResult, Option op return false; } - public static T? GetValueOrDefault(this CommandResult commandResult, Option option) + public static bool TryGetValue(this CommandResult commandResult, string optionName, out T? value) { - if (commandResult is null) - throw new ArgumentNullException(nameof(commandResult)); + // Find the option by name in the command + var option = FindOptionTByName(commandResult, optionName); + if (option is null) - throw new ArgumentNullException(nameof(option)); + { + value = default; + return false; + } + + return TryGetValue(commandResult, option, out value); + } + + public static T? GetValueOrDefault(this CommandResult commandResult, Option option) + { + ArgumentNullException.ThrowIfNull(commandResult); + ArgumentNullException.ThrowIfNull(option); // Find the OptionResult in the parse tree var optionResult = commandResult.GetResult(option); @@ -116,7 +125,7 @@ public static bool TryGetValue(this CommandResult commandResult, Option op // Handle nullable types explicitly - null is a valid value for nullable types if (def is null && typeof(T).IsGenericType && typeof(T).GetGenericTypeDefinition() == typeof(Nullable<>)) { - return default(T); // This will be null for nullable types + return default; // This will be null for nullable types } if (def is T typed) { @@ -130,4 +139,54 @@ public static bool TryGetValue(this CommandResult commandResult, Option op // Using the System.CommandLine API directly to avoid accidental recursion. return optionResult.GetValueOrDefault(); } + + public static T? GetValueOrDefault(this CommandResult commandResult, string optionName) + { + // Find the option by name in the command + var option = FindOptionTByName(commandResult, optionName); + + if (option is null) + { + return default; + } + + return GetValueOrDefault(commandResult, option); + } + + public static T? GetValueWithoutDefault(this CommandResult commandResult, Option option) + { + ArgumentNullException.ThrowIfNull(commandResult); + ArgumentNullException.ThrowIfNull(option); + + // Find the OptionResult in the parse tree + var optionResult = commandResult.GetResult(option); + + // If the option was not provided (null) OR it was implicitly assigned (no token supplied), + // return without considering option default values + if (optionResult is null || optionResult.Implicit) + { + return default; // For value types, this is default(T?) => null; for refs => null + } + + // At this point it was explicitly supplied by the user; get its value. + // Using the System.CommandLine API directly to avoid accidental recursion. + return optionResult.GetValueOrDefault(); + } + + public static T? GetValueWithoutDefault(this CommandResult commandResult, string optionName) + { + // Find the option by name in the command + var option = FindOptionTByName(commandResult, optionName); + + if (option is null) + { + return default; + } + + return GetValueWithoutDefault(commandResult, option); + } + + private static Option? FindOptionTByName(CommandResult commandResult, string optionName) + => commandResult.Command.Options.OfType>() + .FirstOrDefault(o => o.Name == optionName || o.Aliases.Contains(optionName)); } diff --git a/core/Azure.Mcp.Core/src/Extensions/McpServerElicitationExtensions.cs b/core/Azure.Mcp.Core/src/Extensions/McpServerElicitationExtensions.cs index 2003b404c9..657036bc05 100644 --- a/core/Azure.Mcp.Core/src/Extensions/McpServerElicitationExtensions.cs +++ b/core/Azure.Mcp.Core/src/Extensions/McpServerElicitationExtensions.cs @@ -20,7 +20,7 @@ public static class McpServerElicitationExtensions /// A token to monitor for cancellation requests. /// A task that represents the asynchronous elicitation operation. public static async Task RequestElicitationAsync( - this IMcpServer server, + this McpServer server, ElicitationRequestParams request, CancellationToken cancellationToken = default) { @@ -40,8 +40,7 @@ public static async Task RequestElicitationAsync( // Create the proper MCP protocol elicitation request var protocolRequest = new ModelContextProtocol.Protocol.ElicitRequestParams { - Message = request.Message, - RequestedSchema = ConvertToRequestSchema(request.RequestedSchema) + Message = request.Message }; // Send the real elicitation request through the MCP SDK @@ -61,7 +60,7 @@ public static async Task RequestElicitationAsync( /// /// The MCP server instance. /// True if the client supports elicitation, false otherwise. - public static bool SupportsElicitation(this IMcpServer server) + public static bool SupportsElicitation(this McpServer server) { return server?.ClientCapabilities?.Elicitation != null; } @@ -73,7 +72,7 @@ public static bool SupportsElicitation(this IMcpServer server) /// The name of the tool. /// The tool metadata to check. /// True if elicitation should be triggered, false otherwise. - public static bool ShouldTriggerElicitation(this IMcpServer server, string toolName, object? toolMetadata) + public static bool ShouldTriggerElicitation(this McpServer server, string toolName, object? toolMetadata) { if (!server.SupportsElicitation()) { @@ -91,35 +90,4 @@ secretValue is JsonValue jsonValue && return false; } - - /// - /// Converts our ElicitationRequestParams.RequestedSchema to the MCP protocol RequestSchema. - /// - private static RequestSchema ConvertToRequestSchema(JsonObject requestedSchema) - { - var schema = new RequestSchema(); - - // Convert JsonObject schema to RequestSchema - foreach (var property in requestedSchema) - { - if (property.Key == "confirmation" && property.Value != null) - { - schema.Properties["confirmation"] = new BooleanSchema - { - Description = property.Value["description"]?.GetValue() ?? "Confirm to proceed with this operation" - }; - } - } - - // Default boolean confirmation schema if no properties found - if (!schema.Properties.Any()) - { - schema.Properties["confirmation"] = new BooleanSchema - { - Description = "Confirm to proceed with this operation" - }; - } - - return schema; - } } diff --git a/core/Azure.Mcp.Core/src/Extensions/OpenTelemetryExtensions.cs b/core/Azure.Mcp.Core/src/Extensions/OpenTelemetryExtensions.cs index ecd1aaab4b..2ed11f6526 100644 --- a/core/Azure.Mcp.Core/src/Extensions/OpenTelemetryExtensions.cs +++ b/core/Azure.Mcp.Core/src/Extensions/OpenTelemetryExtensions.cs @@ -3,13 +3,17 @@ using System.Reflection; using System.Runtime.InteropServices; +using Azure.Mcp.Core.Areas.Server.Options; using Azure.Mcp.Core.Configuration; +using Azure.Mcp.Core.Helpers; using Azure.Mcp.Core.Services.Telemetry; using Azure.Monitor.OpenTelemetry.Exporter; using Microsoft.Extensions.Azure; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; +using OpenTelemetry.Logs; +using OpenTelemetry.Metrics; using OpenTelemetry.Resources; using OpenTelemetry.Trace; @@ -17,26 +21,13 @@ namespace Azure.Mcp.Core.Extensions; public static class OpenTelemetryExtensions { - private const string DefaultAppInsights = "InstrumentationKey=21e003c0-efee-4d3f-8a98-1868515aa2c9;IngestionEndpoint=https://centralus-2.in.applicationinsights.azure.com/;LiveEndpoint=https://centralus.livediagnostics.monitor.azure.com/;ApplicationId=f14f6a2d-6405-4f88-bd58-056f25fe274f"; + /// + /// The App Insights connection string to send telemetry to Microsoft. + /// + private const string MicrosoftOwnedAppInsightsConnectionString = "InstrumentationKey=21e003c0-efee-4d3f-8a98-1868515aa2c9;IngestionEndpoint=https://centralus-2.in.applicationinsights.azure.com/;LiveEndpoint=https://centralus.livediagnostics.monitor.azure.com/;ApplicationId=f14f6a2d-6405-4f88-bd58-056f25fe274f"; public static void ConfigureOpenTelemetry(this IServiceCollection services) { - services.AddOptions() - .Configure(options => - { - var entryAssembly = Assembly.GetEntryAssembly(); - var assemblyName = entryAssembly?.GetName() ?? new AssemblyName(); - if (assemblyName?.Version != null) - { - options.Version = assemblyName.Version.ToString(); - } - - var collectTelemetry = Environment.GetEnvironmentVariable("AZURE_MCP_COLLECT_TELEMETRY"); - - options.IsTelemetryEnabled = string.IsNullOrEmpty(collectTelemetry) - || (bool.TryParse(collectTelemetry, out var shouldCollect) && shouldCollect); - }); - services.AddSingleton(); if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) @@ -78,13 +69,6 @@ private static void EnableAzureMonitor(this IServiceCollection services) }); #endif - var appInsightsConnectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"); - - if (string.IsNullOrEmpty(appInsightsConnectionString)) - { - appInsightsConnectionString = DefaultAppInsights; - } - services.ConfigureOpenTelemetryTracerProvider((sp, builder) => { var serverConfig = sp.GetRequiredService>(); @@ -96,22 +80,73 @@ private static void EnableAzureMonitor(this IServiceCollection services) builder.AddSource(serverConfig.Value.Name); }); - services.AddOpenTelemetry() + var otelBuilder = services.AddOpenTelemetry() .ConfigureResource(r => { var version = Assembly.GetExecutingAssembly()?.GetName()?.Version?.ToString(); r.AddService("azmcp", version) .AddTelemetrySdk(); - }) - .UseAzureMonitorExporter(options => - { -#if DEBUG - options.EnableLiveMetrics = true; - options.Diagnostics.IsLoggingEnabled = true; - options.Diagnostics.IsLoggingContentEnabled = true; + }); + + var userProvidedAppInsightsConnectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING"); + + if (!string.IsNullOrWhiteSpace(userProvidedAppInsightsConnectionString)) + { + // Configure telemetry to be sent to user-provided Application Insights instance regardless of build configuration. + ConfigureAzureMonitorExporter(otelBuilder, userProvidedAppInsightsConnectionString, "UserProvided"); + } + + // Configure Microsoft-owned telemetry only in RELEASE builds to avoid polluting telemetry during development. +#if RELEASE + // This environment variable can be used to disable Microsoft telemetry collection. + // By default, Microsoft telemetry is enabled. + var microsoftTelemetry = Environment.GetEnvironmentVariable("AZURE_MCP_COLLECT_TELEMETRY_MICROSOFT"); + + bool shouldCollectMicrosoftTelemetry = string.IsNullOrWhiteSpace(microsoftTelemetry) || (bool.TryParse(microsoftTelemetry, out var shouldCollect) && shouldCollect); + + if (shouldCollectMicrosoftTelemetry) + { + ConfigureAzureMonitorExporter(otelBuilder, MicrosoftOwnedAppInsightsConnectionString, "Microsoft"); + } #endif + + var enableOtlp = Environment.GetEnvironmentVariable("AZURE_MCP_ENABLE_OTLP_EXPORTER"); + if (!string.IsNullOrEmpty(enableOtlp) && bool.TryParse(enableOtlp, out var shouldEnable) && shouldEnable) + { + otelBuilder.WithTracing(tracing => tracing.AddOtlpExporter()) + .WithMetrics(metrics => metrics.AddOtlpExporter()) + .WithLogging(logging => logging.AddOtlpExporter()); + } + } + + private static void ConfigureAzureMonitorExporter(OpenTelemetry.OpenTelemetryBuilder otelBuilder, string appInsightsConnectionString, string name) + { + otelBuilder.WithLogging(logging => + { + logging.AddAzureMonitorLogExporter(options => + { options.ConnectionString = appInsightsConnectionString; - }); + }, + name: name); + }); + + otelBuilder.WithMetrics(metrics => + { + metrics.AddAzureMonitorMetricExporter(options => + { + options.ConnectionString = appInsightsConnectionString; + }, + name: name); + }); + + otelBuilder.WithTracing(tracing => + { + tracing.AddAzureMonitorTraceExporter(options => + { + options.ConnectionString = appInsightsConnectionString; + }, + name: name); + }); } } diff --git a/core/Azure.Mcp.Core/src/Extensions/ParseResultExtensions.cs b/core/Azure.Mcp.Core/src/Extensions/ParseResultExtensions.cs index 4cf1e8de7a..8eb5e63551 100644 --- a/core/Azure.Mcp.Core/src/Extensions/ParseResultExtensions.cs +++ b/core/Azure.Mcp.Core/src/Extensions/ParseResultExtensions.cs @@ -8,6 +8,22 @@ public static class ParseResultExtensions public static bool TryGetValue(this ParseResult parseResult, Option option, out T? value) => parseResult.CommandResult.TryGetValue(option, out value); + public static bool TryGetValue(this ParseResult parseResult, string optionName, out T? value) + { + // Find the option by name in the command + var command = parseResult.CommandResult.Command; + var option = command.Options.OfType>() + .FirstOrDefault(o => o.Name == optionName || o.Aliases.Contains(optionName)); + + if (option != null) + { + return parseResult.CommandResult.TryGetValue(option, out value); + } + + value = default; + return false; + } + public static T? GetValueOrDefault(this ParseResult parseResult, Option option) => parseResult.CommandResult.GetValueOrDefault(option); diff --git a/core/Azure.Mcp.Core/src/Helpers/AssemblyHelper.cs b/core/Azure.Mcp.Core/src/Helpers/AssemblyHelper.cs new file mode 100644 index 0000000000..23e47d167a --- /dev/null +++ b/core/Azure.Mcp.Core/src/Helpers/AssemblyHelper.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; + +namespace Azure.Mcp.Core.Helpers; + +/// +/// Utility methods for working with assembly metadata. +/// +public static class AssemblyHelper +{ + /// + /// Gets the version information for an assembly. Uses logic from Azure SDK for .NET to generate the same version string. + /// https://github.com/Azure/azure-sdk-for-net/blob/main/sdk/core/System.ClientModel/src/Pipeline/UserAgentPolicy.cs#L91 + /// For example, an informational version of "6.14.0-rc.116+54d611f7" will return "6.14.0-rc.116" + /// + /// The assembly to extract version information from. + /// A version string without build metadata (everything after '+' is stripped). + /// Thrown when the assembly does not have an AssemblyInformationalVersionAttribute. + public static string GetAssemblyVersion(Assembly assembly) + { + AssemblyInformationalVersionAttribute? versionAttribute = assembly.GetCustomAttribute(); + if (versionAttribute == null) + { + throw new InvalidOperationException( + $"{nameof(AssemblyInformationalVersionAttribute)} is required on assembly '{assembly.FullName}'."); + } + + string version = versionAttribute.InformationalVersion; + + int hashSeparator = version.IndexOf('+'); + if (hashSeparator != -1) + { + version = version.Substring(0, hashSeparator); + } + + return version; + } +} diff --git a/core/Azure.Mcp.Core/src/Helpers/CommandHelper.cs b/core/Azure.Mcp.Core/src/Helpers/CommandHelper.cs new file mode 100644 index 0000000000..dace31fb7b --- /dev/null +++ b/core/Azure.Mcp.Core/src/Helpers/CommandHelper.cs @@ -0,0 +1,36 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine.Parsing; +using Azure.Mcp.Core.Models.Option; + +namespace Azure.Mcp.Core.Helpers +{ + public static class CommandHelper + { + /// + /// Checks if a subscription is available either from the command option or AZURE_SUBSCRIPTION_ID environment variable. + /// + /// The command result to check for the subscription option. + /// True if a subscription is available, false otherwise. + public static bool HasSubscriptionAvailable(CommandResult commandResult) + { + var hasOption = commandResult.HasOptionResult(OptionDefinitions.Common.Subscription.Name); + var hasEnv = !string.IsNullOrEmpty(EnvironmentHelpers.GetAzureSubscriptionId()); + return hasOption || hasEnv; + } + + public static string? GetSubscription(ParseResult parseResult) + { + // Get subscription from command line option or fallback to environment variable + var subscriptionValue = parseResult.GetValueOrDefault(OptionDefinitions.Common.Subscription.Name); + + var envSubscription = EnvironmentHelpers.GetAzureSubscriptionId(); + return (string.IsNullOrEmpty(subscriptionValue) || IsPlaceholder(subscriptionValue)) && !string.IsNullOrEmpty(envSubscription) + ? envSubscription + : subscriptionValue; + } + + private static bool IsPlaceholder(string value) => value.Contains("subscription") || value.Contains("default"); + } +} diff --git a/core/Azure.Mcp.Core/src/Helpers/OptionParsingHelpers.cs b/core/Azure.Mcp.Core/src/Helpers/OptionParsingHelpers.cs new file mode 100644 index 0000000000..723f3bdd85 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Helpers/OptionParsingHelpers.cs @@ -0,0 +1,40 @@ +namespace Azure.Mcp.Core.Helpers; + +public static class OptionParsingHelpers +{ + /// + /// Parses key value pair string options to a dictionary, assuming a format of "Key=Value,Key=Value" (default separators '=' and ',') + /// If duplicate keys are found, the last value wins. + /// + /// Value string containing key-value pairs + /// Key Value pairs as dictionary + public static Dictionary ParseKeyValuePairStringToDictionary(string value, char keyValueSeparator = '=', char pairSeparator = ',') + { + return ParseKeyValuePairStringToDictionary(value, StringComparer.OrdinalIgnoreCase, keyValueSeparator, pairSeparator); + } + + /// + /// Parses key value pair string options to a dictionary, assuming a format of "Key=Value,Key=Value" (default separators '=' and ',') + /// If duplicate keys are found, the last value wins. + /// + /// Value string containing key-value pairs + /// Key Value pairs as dictionary + public static Dictionary ParseKeyValuePairStringToDictionary(string value, StringComparer keyComparer, char keyValueSeparator = '=', char pairSeparator = ',') + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + ArgumentNullException.ThrowIfNull(keyComparer); + + var result = new Dictionary(keyComparer); + var valuePairs = value + .Split(pairSeparator, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Select(x => x.Split(keyValueSeparator, 2, StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries)) + .Where(x => x.Length == 2); + + foreach (var pair in valuePairs) + { + result[pair[0]] = pair[1]; + } + + return result; + } +} diff --git a/core/Azure.Mcp.Core/src/Models/Command/CommandInfo.cs b/core/Azure.Mcp.Core/src/Models/Command/CommandInfo.cs index 2e11bad177..3bd997e8ad 100644 --- a/core/Azure.Mcp.Core/src/Models/Command/CommandInfo.cs +++ b/core/Azure.Mcp.Core/src/Models/Command/CommandInfo.cs @@ -2,12 +2,16 @@ // Licensed under the MIT License. using System.Text.Json.Serialization; -using Azure.Mcp.Core.Models.Option; +using Azure.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Models.Option; namespace Azure.Mcp.Core.Models.Command; public class CommandInfo { + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + [JsonPropertyName("name")] public string Name { get; set; } = string.Empty; @@ -24,4 +28,8 @@ public class CommandInfo [JsonPropertyName("option")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public List? Options { get; set; } + + [JsonPropertyName("metadata")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public ToolMetadata? Metadata { get; set; } } diff --git a/core/Azure.Mcp.Core/src/Models/Elicitation/ElicitationRequestParams.cs b/core/Azure.Mcp.Core/src/Models/Elicitation/ElicitationRequestParams.cs index b13d1222e3..8db47857c6 100644 --- a/core/Azure.Mcp.Core/src/Models/Elicitation/ElicitationRequestParams.cs +++ b/core/Azure.Mcp.Core/src/Models/Elicitation/ElicitationRequestParams.cs @@ -19,7 +19,8 @@ public sealed class ElicitationRequestParams /// /// Gets or sets the JSON schema defining the structure of the expected response. + /// This property is optional and currently not used by the elicitation implementation. /// [JsonPropertyName("requestedSchema")] - public required JsonObject RequestedSchema { get; set; } + public JsonObject? RequestedSchema { get; set; } } diff --git a/core/Azure.Mcp.Core/src/Models/Elicitation/ElicitationSchema.cs b/core/Azure.Mcp.Core/src/Models/Elicitation/ElicitationSchema.cs deleted file mode 100644 index 198c3fad67..0000000000 --- a/core/Azure.Mcp.Core/src/Models/Elicitation/ElicitationSchema.cs +++ /dev/null @@ -1,78 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.Json.Nodes; - -namespace Azure.Mcp.Core.Models.Elicitation; - -/// -/// Utility class for creating elicitation schema objects. -/// -public static class ElicitationSchema -{ - /// - /// Creates a simple string input schema for elicitation. - /// - /// The name of the property to request. - /// The display title for the property. - /// The description of what the property represents. - /// Whether the property is required. - /// A JSON schema object for the elicitation request. - public static JsonObject CreateStringSchema(string propertyName, string title, string description, bool isRequired = true) - { - var schema = new ElicitationSchemaRoot - { - Properties = new Dictionary - { - [propertyName] = new ElicitationSchemaProperty - { - Title = title, - Description = description - } - }, - Required = isRequired ? [propertyName] : null - }; - - return JsonSerializer.SerializeToNode(schema, ModelsJsonContext.Default.ElicitationSchemaRoot)!.AsObject(); - } - - /// - /// Creates a password input schema for elicitation. - /// - /// The name of the property to request. - /// The display title for the property. - /// The description of what the property represents. - /// Whether the property is required. - /// A JSON schema object for the elicitation request. - public static JsonObject CreatePasswordSchema(string propertyName, string title, string description, bool isRequired = true) - { - var schema = new ElicitationSchemaRoot - { - Properties = new Dictionary - { - [propertyName] = new ElicitationSchemaProperty - { - Title = title, - Description = description, - Format = "password" - } - }, - Required = isRequired ? [propertyName] : null - }; - - return JsonSerializer.SerializeToNode(schema, ModelsJsonContext.Default.ElicitationSchemaRoot)!.AsObject(); - } - - /// - /// Creates a secret value input schema for elicitation. - /// - /// The name of the property to request. - /// The display title for the property. - /// The description of what the property represents. - /// Whether the property is required. - /// A JSON schema object for the elicitation request. - public static JsonObject CreateSecretSchema(string propertyName, string title, string description, bool isRequired = true) - { - return CreatePasswordSchema(propertyName, title, description, isRequired); - } -} diff --git a/core/Azure.Mcp.Core/src/Models/Elicitation/ElicitationSchemaModels.cs b/core/Azure.Mcp.Core/src/Models/Elicitation/ElicitationSchemaModels.cs deleted file mode 100644 index 000e7cc51d..0000000000 --- a/core/Azure.Mcp.Core/src/Models/Elicitation/ElicitationSchemaModels.cs +++ /dev/null @@ -1,60 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Text.Json.Serialization; - -namespace Azure.Mcp.Core.Models.Elicitation; - -/// -/// Represents a JSON schema for elicitation requests. -/// -public sealed class ElicitationSchemaRoot -{ - /// - /// Gets or sets the type of the schema object. - /// - [JsonPropertyName("type")] - public string Type { get; set; } = "object"; - - /// - /// Gets or sets the properties of the schema. - /// - [JsonPropertyName("properties")] - public required Dictionary Properties { get; set; } - - /// - /// Gets or sets the list of required property names. - /// - [JsonPropertyName("required")] - public string[]? Required { get; set; } -} - -/// -/// Represents a property within an elicitation schema. -/// -public sealed class ElicitationSchemaProperty -{ - /// - /// Gets or sets the type of the property. - /// - [JsonPropertyName("type")] - public string Type { get; set; } = "string"; - - /// - /// Gets or sets the display title for the property. - /// - [JsonPropertyName("title")] - public required string Title { get; set; } - - /// - /// Gets or sets the description of what the property represents. - /// - [JsonPropertyName("description")] - public required string Description { get; set; } - - /// - /// Gets or sets the format hint for the property (e.g., "password"). - /// - [JsonPropertyName("format")] - public string? Format { get; set; } -} diff --git a/core/Azure.Mcp.Core/src/Models/Metadata/MetadataDefinition.cs b/core/Azure.Mcp.Core/src/Models/Metadata/MetadataDefinition.cs new file mode 100644 index 0000000000..51b573bc58 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Models/Metadata/MetadataDefinition.cs @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.Mcp.Core.Models.Metadata; + +public sealed class MetadataDefinition +{ + /// + /// Gets or sets the boolean value of the metadata property. + /// + [JsonPropertyName("value")] + public bool Value { get; init; } + + /// + /// Gets or sets the description of what the metadata means. + /// + [JsonPropertyName("description")] + public string Description { get; init; } = string.Empty; +} diff --git a/core/Azure.Mcp.Core/src/Models/ModelsJsonContext.cs b/core/Azure.Mcp.Core/src/Models/ModelsJsonContext.cs index 7ae4bc6c02..b6b5204e37 100644 --- a/core/Azure.Mcp.Core/src/Models/ModelsJsonContext.cs +++ b/core/Azure.Mcp.Core/src/Models/ModelsJsonContext.cs @@ -2,6 +2,8 @@ // Licensed under the MIT License. using System.Text.Json.Serialization; +using Azure.Mcp.Core.Areas.Tools.Commands; +using Azure.Mcp.Core.Commands; using Azure.Mcp.Core.Models.Elicitation; namespace Azure.Mcp.Core.Models; @@ -9,8 +11,8 @@ namespace Azure.Mcp.Core.Models; [JsonSerializable(typeof(List))] [JsonSerializable(typeof(CommandResponse))] [JsonSerializable(typeof(ETag), TypeInfoPropertyName = "McpETag")] -[JsonSerializable(typeof(ElicitationSchemaRoot))] -[JsonSerializable(typeof(ElicitationSchemaProperty))] +[JsonSerializable(typeof(ToolMetadata))] +[JsonSerializable(typeof(ToolsListCommand.ToolNamesResult))] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] public sealed partial class ModelsJsonContext : JsonSerializerContext { diff --git a/core/Azure.Mcp.Core/src/Properties/launchSettings.json b/core/Azure.Mcp.Core/src/Properties/launchSettings.json index 39a1632199..0a566088a5 100644 --- a/core/Azure.Mcp.Core/src/Properties/launchSettings.json +++ b/core/Azure.Mcp.Core/src/Properties/launchSettings.json @@ -5,4 +5,4 @@ "commandLineArgs": "server start" } } -} \ No newline at end of file +} diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Authentication/AuthenticationServiceCollectionExtensions.cs b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/AuthenticationServiceCollectionExtensions.cs new file mode 100644 index 0000000000..a08bcebd0c --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/AuthenticationServiceCollectionExtensions.cs @@ -0,0 +1,69 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Identity.Web; + +namespace Azure.Mcp.Core.Services.Azure.Authentication; + +/// +/// Extension methods for configuring Azure authentication services. +/// +public static class AuthenticationServiceCollectionExtensions +{ + /// + /// Adds as a + /// with lifetime + /// into the service collection. + /// + /// The service collection. + /// The service collection. + /// + /// + /// This method registers the single identity token credential provider which uses the hosting + /// environment's identity (e.g., a Managed Identity or a user principal using Azure CLI, Visual + /// Studio, etc.). + /// + /// This method will not override any existing + /// registration. It can be overridden as needed for on-behalf-of web APIs using + /// . + /// + public static IServiceCollection AddSingleIdentityTokenCredentialProvider(this IServiceCollection services) + { + services.TryAddSingleton(); + return services; + } + + /// + /// Adds as a + /// with lifetime + /// into the service collection, along with all required dependencies. + /// + /// The service collection. + /// + /// The authentication builder from + /// that will be used to enable token acquisition for downstream API calls. + /// + /// The service collection. + /// + /// This method will override any existing registration. + /// + public static IServiceCollection AddHttpOnBehalfOfTokenCredentialProvider( + this IServiceCollection services, + MicrosoftIdentityWebApiAuthenticationBuilderWithConfiguration authBuilder) + { + // Dependencies - directly in constructor. + services.AddHttpContextAccessor(); + + // Dependencies - indirectly required to get MicrosoftIdentityTokenCredential. + authBuilder.EnableTokenAcquisitionToCallDownstreamApi() + .AddInMemoryTokenCaches(); + services.AddMicrosoftIdentityAzureTokenCredential(); + + // Register the OBO token provider. This uses AddSingleton (not TryAdd) to override + // any default registration, since OBO is an explicit configuration choice. + services.AddSingleton(); + return services; + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs index 7bad1661e7..35889c984d 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/CustomChainedCredential.cs @@ -15,20 +15,59 @@ namespace Azure.Mcp.Core.Services.Azure.Authentication; /// InteractiveBrowserCredential to provide a seamless authentication experience. /// /// +/// +/// DO NOT INSTANTIATE THIS CLASS DIRECTLY. Use dependency injection to get an instance of +/// from . +/// +/// /// The credential chain behavior can be controlled via the AZURE_TOKEN_CREDENTIALS environment variable: -/// - "dev": Visual Studio → Visual Studio Code → Azure CLI → Azure PowerShell → Azure Developer CLI -/// - "prod": Environment → Workload Identity → Managed Identity -/// - Specific credential name (e.g., "AzureCliCredential"): Only that credential -/// - Not set or empty: Development chain (Environment → Visual Studio → Visual Studio Code → Azure CLI → Azure PowerShell → Azure Developer CLI) -/// +/// +/// +/// +/// Value +/// Behavior +/// +/// +/// "dev" +/// Visual Studio → Visual Studio Code → Azure CLI → Azure PowerShell → Azure Developer CLI → InteractiveBrowserCredential +/// +/// +/// "prod" +/// Environment → Workload Identity → Managed Identity (no interactive fallback) +/// +/// +/// Specific credential name +/// Only that credential (e.g., "AzureCliCredential" or "ManagedIdentityCredential") with no fallback +/// +/// +/// Not set or empty +/// Development chain (Environment → Visual Studio → Visual Studio Code → Azure CLI → Azure PowerShell → Azure Developer CLI) + InteractiveBrowserCredential fallback +/// +/// +/// /// By default, production credentials (Workload Identity and Managed Identity) are excluded unless explicitly requested via AZURE_TOKEN_CREDENTIALS="prod". -/// +/// +/// /// Special behavior: When running in VS Code context (VSCODE_PID environment variable is set) and AZURE_TOKEN_CREDENTIALS is not explicitly specified, /// Visual Studio Code credential is automatically prioritized first in the chain. -/// -/// After the credential chain, Interactive Browser Authentication with Identity Broker is always added as the final fallback. +/// +/// +/// InteractiveBrowserCredential with Identity Broker is added as a final fallback only when: +/// - AZURE_TOKEN_CREDENTIALS is not set (default behavior) +/// - AZURE_TOKEN_CREDENTIALS="dev" (development credentials with interactive fallback) +/// - AZURE_TOKEN_CREDENTIALS="InteractiveBrowserCredential" (explicitly requested) +/// +/// +/// It is NOT added when: +/// - AZURE_TOKEN_CREDENTIALS="prod" (production credentials only, fail fast if unavailable) +/// - AZURE_TOKEN_CREDENTIALS=specific credential name (user wants only that credential, fail fast) +/// +/// +/// For User-Assigned Managed Identity, set the AZURE_CLIENT_ID environment variable to the client ID of the managed identity. +/// If not set, System-Assigned Managed Identity will be used. +/// /// -public class CustomChainedCredential(string? tenantId = null, ILogger? logger = null) : TokenCredential +internal class CustomChainedCredential(string? tenantId = null, ILogger? logger = null) : TokenCredential { private TokenCredential? _credential; private readonly ILogger? _logger = logger; @@ -58,6 +97,21 @@ private static bool ShouldUseOnlyBrokerCredential() private static TokenCredential CreateCredential(string? tenantId, ILogger? logger = null) { + + // Check if AZURE_TOKEN_CREDENTIALS is explicitly set + string? tokenCredentials = Environment.GetEnvironmentVariable(TokenCredentialsEnvVarName); + bool hasExplicitCredentialSetting = !string.IsNullOrEmpty(tokenCredentials); + +#if DEBUG + bool isPlaybackMode = string.Equals(tokenCredentials, "PlaybackTokenCredential", StringComparison.OrdinalIgnoreCase); + // Short-circuit for playback to avoid any real auth & interactive prompts. + if (isPlaybackMode) + { + logger?.LogDebug("Playback mode detected: using PlaybackTokenCredential."); + return new PlaybackTokenCredential(); + } +#endif + string? authRecordJson = Environment.GetEnvironmentVariable(AuthenticationRecordEnvVarName); AuthenticationRecord? authRecord = null; if (!string.IsNullOrEmpty(authRecordJson)) @@ -77,10 +131,6 @@ private static TokenCredential CreateCredential(string? tenantId, ILogger credenti private static void AddManagedIdentityCredential(List credentials) { - credentials.Add(new SafeTokenCredential(new ManagedIdentityCredential(), "ManagedIdentityCredential")); + // Check if AZURE_CLIENT_ID is set for User-Assigned Managed Identity + string? clientId = Environment.GetEnvironmentVariable("AZURE_CLIENT_ID"); + + ManagedIdentityCredential managedIdentityCredential = string.IsNullOrEmpty(clientId) + ? new ManagedIdentityCredential() // System-Assigned MI + : new ManagedIdentityCredential(clientId); // User-Assigned MI + + credentials.Add(new SafeTokenCredential(managedIdentityCredential, "ManagedIdentityCredential")); } private static void AddVisualStudioCredential(List credentials, string? tenantId) diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Authentication/HttpOnBehalfOfTokenCredentialProvider.cs b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/HttpOnBehalfOfTokenCredentialProvider.cs new file mode 100644 index 0000000000..176a379af8 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/HttpOnBehalfOfTokenCredentialProvider.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Identity.Web; + +namespace Azure.Mcp.Core.Services.Azure.Authentication; + +public class HttpOnBehalfOfTokenCredentialProvider : IAzureTokenCredentialProvider +{ + private readonly IHttpContextAccessor _httpContextAccessor; + private readonly ILogger _logger; + + public HttpOnBehalfOfTokenCredentialProvider( + IHttpContextAccessor httpContextAccessor, + ILogger logger) + { + _httpContextAccessor = httpContextAccessor; + _logger = logger; + } + + /// + public Task GetTokenCredentialAsync(string? tenantId, CancellationToken cancellationToken) + { + if (_httpContextAccessor.HttpContext is not HttpContext httpContext) + { + throw new InvalidOperationException("There is no ongoing HTTP request."); + } + + if (httpContext.User.Identity?.IsAuthenticated != true) + { + throw new InvalidOperationException( + "The current HTTP request must be authenticated to make an on-behalf-of token request."); + } + + if (tenantId is not null) + { + if (httpContext.User.FindFirst("tid")?.Value is string tidClaim + && tidClaim != tenantId) + { + _logger.LogWarning( + "The requested token tenant '{GetTokenTenant}' does not match the tenant of the authenticated user '{TidClaim}'. Going to throw.", + tenantId, + tidClaim); + + throw new InvalidOperationException( + $"The requested token tenant '{tenantId}' does not match the tenant of the authenticated user '{tidClaim}'."); + } + } + + // MicrosoftIdentityTokenCredential is registered as scoped, so we + // can get it from the request services to ensure we get the right instance. + MicrosoftIdentityTokenCredential tokenCredential = httpContext + .RequestServices + .GetRequiredService(); + return Task.FromResult(tokenCredential); + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Authentication/IAzureTokenCredentialProvider.cs b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/IAzureTokenCredentialProvider.cs new file mode 100644 index 0000000000..3447ec48b0 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/IAzureTokenCredentialProvider.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; +using Azure.Mcp.Core.Services.Azure.Tenant; +using Microsoft.Extensions.DependencyInjection; + +namespace Azure.Mcp.Core.Services.Azure.Authentication; + +/// +/// Providers instances of appropriate for the current environment. +/// Implementations are expected to be of , however, in +/// multi-user enviornments using on-behalf-of downstream authentication, the implementation +/// must return credentials within the context of the user in the current execution context. +/// +/// +/// +/// Callers can either directly depend on this interface or indirectly depend on it through +/// . +/// +/// +/// Implementors of this interface are responsible for generating, caching, and retrieving tokens +/// that can be used for authentication or authorization purposes. The specific type of the +/// is opaque to the caller of this interface as it will vary based +/// on the environment and configured authentication. +/// +/// +public interface IAzureTokenCredentialProvider +{ + /// + /// Gets an instance of applicable for the current environment + /// and downstream authentication configuration. + /// + /// An optional tenant ID to use for the token request. Use of this may + /// cause to be thrown. See the exceptions section for + /// details. + /// + /// A cancellation token. + /// + /// A task representing the asynchronous operation, with a value of . + /// + /// Thrown when the operation has been cancelled. + /// + /// + /// Thrown when a credential cannot be provided. This can happen for reasons that vary based on the + /// underlying implementation and authentication configuration of the environment. The + /// of the should include + /// additional details. For example, in multi-user environments using on-behalf-of will + /// throw if a non- is provided that does + /// not match the tenant of the authenticated user in the current execution context. + /// + Task GetTokenCredentialAsync( + string? tenantId, + CancellationToken cancellation); +} diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Authentication/PlaybackTokenCredential.cs b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/PlaybackTokenCredential.cs new file mode 100644 index 0000000000..7046d7dd8f --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/PlaybackTokenCredential.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; + +namespace Azure.Mcp.Core.Services.Azure.Authentication; + +/// +/// Token credential used during test playback to avoid any interactive or real authentication. +/// Returns a deterministic sanitized token value accepted by the test proxy recordings. +/// +public sealed class PlaybackTokenCredential : TokenCredential +{ + private static readonly string s_tokenValue = "Sanitized"; // Matches proxy sanitizer expectations. + private static readonly DateTimeOffset s_expiration = DateTimeOffset.UtcNow.AddHours(1); + + public override AccessToken GetToken(TokenRequestContext requestContext, CancellationToken cancellationToken) + => new(s_tokenValue, s_expiration); + + public override ValueTask GetTokenAsync(TokenRequestContext requestContext, CancellationToken cancellationToken) + => ValueTask.FromResult(new AccessToken(s_tokenValue, s_expiration)); +} diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Authentication/SingleIdentityTokenCredentialProvider.cs b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/SingleIdentityTokenCredentialProvider.cs new file mode 100644 index 0000000000..d0131efbbb --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/SingleIdentityTokenCredentialProvider.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Core; +using Microsoft.Extensions.Logging; + +namespace Azure.Mcp.Core.Services.Azure.Authentication; + +/// +/// Implementation of that uses and caches +/// instances of . +/// +public class SingleIdentityTokenCredentialProvider : IAzureTokenCredentialProvider +{ + private readonly ILoggerFactory _loggerFactory; + private readonly TokenCredential _credential; + private readonly Dictionary _tenantSpecificCredentials + = new(StringComparer.OrdinalIgnoreCase); + + public SingleIdentityTokenCredentialProvider(ILoggerFactory loggerFactory) + { + _loggerFactory = loggerFactory; + _credential = new CustomChainedCredential( + null, + _loggerFactory.CreateLogger() + ); + } + + /// + public Task GetTokenCredentialAsync( + string? tenantId, + CancellationToken cancellation) + { + if (tenantId is null) + { + return Task.FromResult(_credential); + } + + if (!_tenantSpecificCredentials.TryGetValue(tenantId, out TokenCredential? tenantCredential)) + { + lock (_tenantSpecificCredentials) + { + if (!_tenantSpecificCredentials.TryGetValue(tenantId, out tenantCredential)) + { + tenantCredential = new CustomChainedCredential( + tenantId, + _loggerFactory.CreateLogger() + ); + _tenantSpecificCredentials[tenantId] = tenantCredential; + } + } + } + + return Task.FromResult(tenantCredential); + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Authentication/TimeoutTokenCredential.cs b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/TimeoutTokenCredential.cs index 75f90c9bf5..249b49abc0 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/Authentication/TimeoutTokenCredential.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/Authentication/TimeoutTokenCredential.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using Azure.Core; +using Azure.Identity; namespace Azure.Mcp.Core.Services.Azure.Authentication; @@ -19,6 +20,14 @@ public override AccessToken GetToken(TokenRequestContext requestContext, Cancell { return _innerCredential.GetToken(requestContext, cts.Token); } + catch (AuthenticationFailedException ex) when (ex.Message.Contains("Interactive requests with mac broker enabled must be executed on the main thread on macOS", StringComparison.OrdinalIgnoreCase)) + { + throw new AuthenticationFailedException( + "Authentication is not configured correctly." + + "Please authenticate using Azure CLI ('az login'), Azure PowerShell ('Connect-AzAccount'), or Azure Developer CLI ('azd auth login') instead. " + + "Alternatively, set AZURE_TOKEN_CREDENTIALS environment variable to use a specific credential provider.", + ex); + } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { throw new TimeoutException($"Authentication timed out after {_timeout.TotalSeconds} seconds."); @@ -34,6 +43,14 @@ public override async ValueTask GetTokenAsync(TokenRequestContext r { return await _innerCredential.GetTokenAsync(requestContext, cts.Token).ConfigureAwait(false); } + catch (AuthenticationFailedException ex) when (ex.Message.Contains("Interactive requests with mac broker enabled must be executed on the main thread on macOS", StringComparison.OrdinalIgnoreCase)) + { + throw new AuthenticationFailedException( + "Authentication is not configured correctly." + + "Please authenticate using Azure CLI ('az login'), Azure PowerShell ('Connect-AzAccount'), or Azure Developer CLI ('azd auth login') instead. " + + "Alternatively, set AZURE_TOKEN_CREDENTIALS environment variable to use a specific credential provider.", + ex); + } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) { throw new TimeoutException($"Authentication timed out after {_timeout.TotalSeconds} seconds."); diff --git a/core/Azure.Mcp.Core/src/Services/Azure/BaseAzureResourceService.cs b/core/Azure.Mcp.Core/src/Services/Azure/BaseAzureResourceService.cs index b14b480238..5c9f33daba 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/BaseAzureResourceService.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/BaseAzureResourceService.cs @@ -11,7 +11,6 @@ using Azure.ResourceManager.ResourceGraph; using Azure.ResourceManager.ResourceGraph.Models; using Azure.ResourceManager.Resources; -using Microsoft.Extensions.Logging; namespace Azure.Mcp.Core.Services.Azure; @@ -21,11 +20,10 @@ namespace Azure.Mcp.Core.Services.Azure; /// public abstract class BaseAzureResourceService( ISubscriptionService subscriptionService, - ITenantService tenantService, - ILoggerFactory? loggerFactory = null) : BaseAzureService(tenantService, loggerFactory) + ITenantService tenantService) + : BaseAzureService(tenantService) { private readonly ISubscriptionService _subscriptionService = subscriptionService ?? throw new ArgumentNullException(nameof(subscriptionService)); - private readonly ITenantService _tenantService = tenantService ?? throw new ArgumentNullException(nameof(tenantService)); /// /// Gets the tenant resource for the specified subscription. @@ -35,13 +33,13 @@ public abstract class BaseAzureResourceService( /// The tenant resource associated with the subscription private async Task GetTenantResourceAsync(Guid? tenantId, CancellationToken cancellationToken = default) { - if (tenantId == null || tenantId == Guid.Empty) + if (tenantId == null) { - throw new ArgumentException("Tenant ID cannot be null or empty", nameof(tenantId)); + throw new ArgumentException("Tenant ID cannot be null.", nameof(tenantId)); } // Get all tenants and find the matching one (GetTenants already has caching) - var allTenants = await _tenantService.GetTenants(); + var allTenants = await TenantService.GetTenants(cancellationToken); var tenantResource = allTenants.FirstOrDefault(t => t.Data.TenantId == tenantId.Value); if (tenantResource == null) @@ -75,6 +73,7 @@ private async Task ValidateResourceGroupExistsAsync(SubscriptionResource s /// The subscription ID or name /// Optional retry policy configuration /// Function to convert JsonElement to the target type + /// Optional table name to query (default: "resources") /// Optional additional KQL filter conditions /// Maximum number of results to return (default: 50) /// Cancellation token @@ -85,19 +84,20 @@ protected async Task> ExecuteResourceQueryAsync( string subscription, RetryPolicyOptions? retryPolicy, Func converter, + string? tableName = "resources", string? additionalFilter = null, int limit = 50, CancellationToken cancellationToken = default) { - ValidateRequiredParameters(resourceType, subscription); + ValidateRequiredParameters((nameof(resourceType), resourceType), (nameof(subscription), subscription)); ArgumentNullException.ThrowIfNull(converter); var results = new List(); - var subscriptionResource = await _subscriptionService.GetSubscription(subscription, null, retryPolicy); + var subscriptionResource = await _subscriptionService.GetSubscription(subscription, null, retryPolicy, cancellationToken); var tenantResource = await GetTenantResourceAsync(subscriptionResource.Data.TenantId, cancellationToken); - var queryFilter = $"Resources | where type =~ '{EscapeKqlString(resourceType)}'"; + var queryFilter = $"{tableName} | where type =~ '{EscapeKqlString(resourceType)}'"; if (!string.IsNullOrEmpty(resourceGroup)) { if (!await ValidateResourceGroupExistsAsync(subscriptionResource, resourceGroup, cancellationToken)) @@ -152,16 +152,17 @@ protected async Task> ExecuteResourceQueryAsync( string subscription, RetryPolicyOptions? retryPolicy, Func converter, + string? tableName = "resources", string? additionalFilter = null, CancellationToken cancellationToken = default) where T : class { - ValidateRequiredParameters(resourceType, subscription); + ValidateRequiredParameters((nameof(resourceType), resourceType), (nameof(subscription), subscription)); ArgumentNullException.ThrowIfNull(converter); - var subscriptionResource = await _subscriptionService.GetSubscription(subscription, null, retryPolicy); + var subscriptionResource = await _subscriptionService.GetSubscription(subscription, null, retryPolicy, cancellationToken); var tenantResource = await GetTenantResourceAsync(subscriptionResource.Data.TenantId, cancellationToken); - var queryFilter = $"Resources | where type =~ '{EscapeKqlString(resourceType)}'"; + var queryFilter = $"{tableName} | where type =~ '{EscapeKqlString(resourceType)}'"; if (!string.IsNullOrEmpty(resourceGroup)) { if (!await ValidateResourceGroupExistsAsync(subscriptionResource, resourceGroup, cancellationToken)) diff --git a/core/Azure.Mcp.Core/src/Services/Azure/BaseAzureService.cs b/core/Azure.Mcp.Core/src/Services/Azure/BaseAzureService.cs index 235bdd281e..a66d3cd12d 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/BaseAzureService.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/BaseAzureService.cs @@ -4,41 +4,114 @@ using System.Reflection; using System.Runtime.Versioning; using Azure.Core; +using Azure.Core.Pipeline; using Azure.Mcp.Core.Options; -using Azure.Mcp.Core.Services.Azure.Authentication; using Azure.Mcp.Core.Services.Azure.Tenant; using Azure.ResourceManager; -using Microsoft.Extensions.Logging; namespace Azure.Mcp.Core.Services.Azure; -public abstract class BaseAzureService(ITenantService? tenantService = null, ILoggerFactory? loggerFactory = null) +public abstract class BaseAzureService { - private static readonly UserAgentPolicy s_sharedUserAgentPolicy; - public static readonly string DefaultUserAgent; - - private CustomChainedCredential? _credential; - private string? _lastTenantId; - private ArmClient? _armClient; - private string? _lastArmClientTenantId; - private RetryPolicyOptions? _lastRetryPolicy; - private readonly ITenantService? _tenantService = tenantService; - private readonly ILoggerFactory? _loggerFactory = loggerFactory; - - protected ILoggerFactory LoggerFactory => _loggerFactory ?? Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance; + private static UserAgentPolicy s_sharedUserAgentPolicy; + private static string? s_userAgent; + private static volatile bool s_initialized = false; + private static readonly object s_initializeLock = new(); + private readonly ITenantService? _tenantServiceDoNotUseDirectly; + + // Cache assembly metadata to avoid repeated reflection + private static readonly string s_version; + private static readonly string s_framework; + private static readonly string s_platform; + private static readonly string s_defaultUserAgent; static BaseAzureService() { var assembly = typeof(BaseAzureService).Assembly; - var version = assembly.GetCustomAttribute()?.Version; - var framework = assembly.GetCustomAttribute()?.FrameworkName; - var platform = System.Runtime.InteropServices.RuntimeInformation.OSDescription; + s_version = assembly.GetCustomAttribute()?.Version ?? "unknown"; + s_framework = assembly.GetCustomAttribute()?.FrameworkName ?? "unknown"; + s_platform = System.Runtime.InteropServices.RuntimeInformation.OSDescription; + + // Initialize the default user agent policy without transport type + s_defaultUserAgent = $"azmcp/{s_version} ({s_framework}; {s_platform})"; + s_sharedUserAgentPolicy = new UserAgentPolicy(s_defaultUserAgent); + } + + /// + /// Initializes the user agent policy to include the transport type for all Azure service calls. + /// This method must be called once during application startup before creating any instances. + /// Subsequent calls will be safely ignored to ensure the policy is initialized only once. + /// + /// The transport type (e.g., "stdio", "http"). Cannot be null or empty. + /// Thrown when is null or empty. + /// + /// The user agent string will be formatted as: azmcp/{version} azmcp-{transport}/{version} ({framework}; {platform}) + /// + public static void InitializeUserAgentPolicy(string transportType) + { + ArgumentException.ThrowIfNullOrWhiteSpace(transportType, nameof(transportType)); + + // Ensure this method is called only once + lock (s_initializeLock) + { + if (s_initialized) + { + return; + } + + s_userAgent = $"azmcp/{s_version} azmcp-{transportType}/{s_version} ({s_framework}; {s_platform})"; + s_sharedUserAgentPolicy = new UserAgentPolicy(s_userAgent); - DefaultUserAgent = $"azmcp/{version} ({framework}; {platform})"; - s_sharedUserAgentPolicy = new UserAgentPolicy(DefaultUserAgent); + s_initialized = true; + } } - protected string UserAgent { get; } = DefaultUserAgent; + /// + /// Initializes a new instance of the class. + /// + /// + /// An used for Azure API calls. + /// + protected BaseAzureService(ITenantService tenantService) + { + ArgumentNullException.ThrowIfNull(tenantService, nameof(tenantService)); + TenantService = tenantService; + UserAgent = s_userAgent ?? s_defaultUserAgent; + } + + /// + /// DO NOT USE THIS CONSTRUCTOR. + /// + /// + /// This is only to be used by to overcome a circular dependency on itself. + internal BaseAzureService() + { + UserAgent = s_userAgent ?? s_defaultUserAgent; + } + + protected string UserAgent { get; } + + /// + /// Gets or initializes the tenant service for resolving tenant IDs and obtaining credentials. + /// + /// + /// Do not this. The initializer is just for + /// to overcome a circular dependency on itself. In all other cases, pass the constructor + /// a non-null . + /// + protected ITenantService TenantService + { + get + { + return _tenantServiceDoNotUseDirectly + ?? throw new InvalidOperationException($"{nameof(TenantService)} is not set. This is a code bug. Use the {nameof(BaseAzureService)} constructor with a non-null {nameof(ITenantService)}."); + } + + init + { + _tenantServiceDoNotUseDirectly = value; + } + } /// /// Escapes a string value for safe use in KQL queries to prevent injection attacks. @@ -57,29 +130,27 @@ protected static string EscapeKqlString(string value) return value.Replace("\\", "\\\\").Replace("'", "''"); } - protected async Task ResolveTenantIdAsync(string? tenant) + protected async Task ResolveTenantIdAsync(string? tenant, CancellationToken cancellationToken) { - if (tenant == null || _tenantService == null) + if (tenant == null) return tenant; - return await _tenantService.GetTenantId(tenant); + return await TenantService.GetTenantId(tenant, cancellationToken); } - protected async Task GetCredential(string? tenant = null) + protected async Task GetCredential(CancellationToken cancellationToken) { - var tenantId = string.IsNullOrEmpty(tenant) ? null : await ResolveTenantIdAsync(tenant); + // TODO @vukelich: separate PR for cancellationToken to be required, not optional default + return await GetCredential(null, cancellationToken); + } - // Return cached credential if it exists and tenant ID hasn't changed - if (_credential != null && _lastTenantId == tenantId) - { - return _credential; - } + protected async Task GetCredential(string? tenant, CancellationToken cancellationToken) + { + // TODO @vukelich: separate PR for cancellationToken to be required, not optional default + var tenantId = string.IsNullOrEmpty(tenant) ? null : await ResolveTenantIdAsync(tenant, cancellationToken); try { - ILogger? logger = _loggerFactory?.CreateLogger(); - _credential = new CustomChainedCredential(tenantId, logger); - _lastTenantId = tenantId; - return _credential; + return await TenantService!.GetTokenCredentialAsync(tenantId, cancellationToken); } catch (Exception ex) { @@ -90,7 +161,6 @@ protected async Task GetCredential(string? tenant = null) protected static T AddDefaultPolicies(T clientOptions) where T : ClientOptions { clientOptions.AddPolicy(s_sharedUserAgentPolicy, HttpPipelinePosition.BeforeTransport); - return clientOptions; } @@ -131,35 +201,28 @@ protected static T ConfigureRetryPolicy(T clientOptions, RetryPolicyOptions? } /// - /// Creates an Azure Resource Manager client with optional retry policy + /// Creates an Azure Resource Manager client with an optional retry policy. /// - /// Optional Azure tenant ID or name - /// Optional retry policy configuration - /// Optional ARM client options - protected async Task CreateArmClientAsync(string? tenant = null, RetryPolicyOptions? retryPolicy = null, ArmClientOptions? armClientOptions = null) + /// Optional Azure tenant ID or name. + /// Optional retry policy configuration. + /// Optional ARM client options. + protected async Task CreateArmClientAsync( + string? tenantIdOrName = null, + RetryPolicyOptions? retryPolicy = null, + ArmClientOptions? armClientOptions = null, + CancellationToken cancellationToken = default) { - var tenantId = await ResolveTenantIdAsync(tenant); - - // Return cached client if parameters match - if (_armClient != null && - _lastArmClientTenantId == tenantId && - armClientOptions == null && - RetryPolicyOptions.AreEqual(_lastRetryPolicy, retryPolicy)) - { - return _armClient; - } + var tenantId = await ResolveTenantIdAsync(tenantIdOrName, cancellationToken); try { - var credential = await GetCredential(tenantId); - var options = armClientOptions ?? new ArmClientOptions(); + TokenCredential credential = await GetCredential(tenantId, cancellationToken); + ArmClientOptions options = armClientOptions ?? new(); + options.Transport = new HttpClientTransport(TenantService.GetClient()); ConfigureRetryPolicy(AddDefaultPolicies(options), retryPolicy); - _armClient = new ArmClient(credential, default, options); - _lastArmClientTenantId = tenantId; - _lastRetryPolicy = retryPolicy; - - return _armClient; + ArmClient armClient = new(credential, defaultSubscriptionId: default, options); + return armClient; } catch (Exception ex) { @@ -167,19 +230,6 @@ protected async Task CreateArmClientAsync(string? tenant = null, Retr } } - /// - /// Validates that the provided parameters are not null or empty - /// - /// Array of parameters to validate - /// Thrown when any parameter is null or empty - protected static void ValidateRequiredParameters(params string?[] parameters) - { - foreach (var param in parameters) - { - ArgumentException.ThrowIfNullOrEmpty(param); - } - } - /// /// Validates that the provided named parameters are not null or empty /// diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Models/ResourceSku.cs b/core/Azure.Mcp.Core/src/Services/Azure/Models/ResourceSku.cs index 1ae47875b3..944ce830d7 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/Models/ResourceSku.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/Models/ResourceSku.cs @@ -3,7 +3,6 @@ namespace Azure.Mcp.Core.Services.Azure.Models; - /// /// A class representing the Resource Sku data model. /// diff --git a/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/IResourceGroupService.cs b/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/IResourceGroupService.cs index 0cb976675a..1d63bf017c 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/IResourceGroupService.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/IResourceGroupService.cs @@ -12,4 +12,5 @@ public interface IResourceGroupService Task> GetResourceGroups(string subscriptionId, string? tenant = null, RetryPolicyOptions? retryPolicy = null); Task GetResourceGroup(string subscriptionId, string resourceGroupName, string? tenant = null, RetryPolicyOptions? retryPolicy = null); Task GetResourceGroupResource(string subscriptionId, string resourceGroupName, string? tenant = null, RetryPolicyOptions? retryPolicy = null); + Task CreateOrUpdateResourceGroup(string subscriptionId, string resourceGroupName, string location, string? tenant = null, RetryPolicyOptions? retryPolicy = null); } diff --git a/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/ResourceGroupService.cs b/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/ResourceGroupService.cs index dce3945d6a..473120297c 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/ResourceGroupService.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/ResourceGroup/ResourceGroupService.cs @@ -4,13 +4,17 @@ using Azure.Mcp.Core.Models.ResourceGroup; using Azure.Mcp.Core.Options; using Azure.Mcp.Core.Services.Azure.Subscription; +using Azure.Mcp.Core.Services.Azure.Tenant; using Azure.Mcp.Core.Services.Caching; using Azure.ResourceManager.Resources; namespace Azure.Mcp.Core.Services.Azure.ResourceGroup; -public class ResourceGroupService(ICacheService cacheService, ISubscriptionService subscriptionService) - : BaseAzureService, IResourceGroupService +public class ResourceGroupService( + ICacheService cacheService, + ISubscriptionService subscriptionService, + ITenantService tenantService) + : BaseAzureService(tenantService), IResourceGroupService { private readonly ICacheService _cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService)); private readonly ISubscriptionService _subscriptionService = subscriptionService ?? throw new ArgumentNullException(nameof(subscriptionService)); @@ -18,16 +22,16 @@ public class ResourceGroupService(ICacheService cacheService, ISubscriptionServi private const string CacheKey = "resourcegroups"; private static readonly TimeSpan s_cacheDuration = TimeSpan.FromHours(1); - public async Task> GetResourceGroups(string subscription, string? tenant = null, RetryPolicyOptions? retryPolicy = null) + public async Task> GetResourceGroups(string subscription, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) { - ValidateRequiredParameters(subscription); + ValidateRequiredParameters((nameof(subscription), subscription)); - var subscriptionResource = await _subscriptionService.GetSubscription(subscription, tenant, retryPolicy); + var subscriptionResource = await _subscriptionService.GetSubscription(subscription, tenant, retryPolicy, cancellationToken); var subscriptionId = subscriptionResource.Data.SubscriptionId; // Try to get from cache first var cacheKey = $"{CacheKey}_{subscriptionId}_{tenant ?? "default"}"; - var cachedResults = await _cacheService.GetAsync>(CacheGroup, cacheKey, s_cacheDuration); + var cachedResults = await _cacheService.GetAsync>(CacheGroup, cacheKey, s_cacheDuration, cancellationToken); if (cachedResults != null) { return cachedResults; @@ -37,15 +41,15 @@ public async Task> GetResourceGroups(string subscription try { var resourceGroups = await subscriptionResource.GetResourceGroups() - .GetAllAsync() + .GetAllAsync(cancellationToken: cancellationToken) .Select(rg => new ResourceGroupInfo( rg.Data.Name, rg.Data.Id.ToString(), rg.Data.Location.ToString())) - .ToListAsync(); + .ToListAsync(cancellationToken: cancellationToken); // Cache the results - await _cacheService.SetAsync(CacheGroup, cacheKey, resourceGroups, s_cacheDuration); + await _cacheService.SetAsync(CacheGroup, cacheKey, resourceGroups, s_cacheDuration, cancellationToken); return resourceGroups; } @@ -55,16 +59,16 @@ public async Task> GetResourceGroups(string subscription } } - public async Task GetResourceGroup(string subscription, string resourceGroupName, string? tenant = null, RetryPolicyOptions? retryPolicy = null) + public async Task GetResourceGroup(string subscription, string resourceGroupName, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) { - ValidateRequiredParameters(subscription, resourceGroupName); + ValidateRequiredParameters((nameof(subscription), subscription), (nameof(resourceGroupName), resourceGroupName)); - var subscriptionResource = await _subscriptionService.GetSubscription(subscription, tenant, retryPolicy); + var subscriptionResource = await _subscriptionService.GetSubscription(subscription, tenant, retryPolicy, cancellationToken); var subscriptionId = subscriptionResource.Data.SubscriptionId; // Try to get from cache first var cacheKey = $"{CacheKey}_{subscriptionId}_{tenant ?? "default"}"; - var cachedResults = await _cacheService.GetAsync>(CacheGroup, cacheKey, s_cacheDuration); + var cachedResults = await _cacheService.GetAsync>(CacheGroup, cacheKey, s_cacheDuration, cancellationToken); if (cachedResults != null) { return cachedResults.FirstOrDefault(rg => rg.Name.Equals(resourceGroupName, StringComparison.OrdinalIgnoreCase)); @@ -72,7 +76,7 @@ public async Task> GetResourceGroups(string subscription try { - var rg = await GetResourceGroupResource(subscription, resourceGroupName, tenant, retryPolicy); + var rg = await GetResourceGroupResource(subscription, resourceGroupName, tenant, retryPolicy, cancellationToken); if (rg == null) { return null; @@ -89,15 +93,15 @@ public async Task> GetResourceGroups(string subscription } } - public async Task GetResourceGroupResource(string subscription, string resourceGroupName, string? tenant = null, RetryPolicyOptions? retryPolicy = null) + public async Task GetResourceGroupResource(string subscription, string resourceGroupName, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) { - ValidateRequiredParameters(subscription, resourceGroupName); + ValidateRequiredParameters((nameof(subscription), subscription), (nameof(resourceGroupName), resourceGroupName)); try { - var subscriptionResource = await _subscriptionService.GetSubscription(subscription, tenant, retryPolicy); + var subscriptionResource = await _subscriptionService.GetSubscription(subscription, tenant, retryPolicy, cancellationToken); var resourceGroupResponse = await subscriptionResource.GetResourceGroups() - .GetAsync(resourceGroupName) + .GetAsync(resourceGroupName, cancellationToken) .ConfigureAwait(false); return resourceGroupResponse?.Value; @@ -107,4 +111,22 @@ public async Task> GetResourceGroups(string subscription throw new Exception($"Error retrieving resource group {resourceGroupName}: {ex.Message}", ex); } } + + public async Task CreateOrUpdateResourceGroup(string subscription, string resourceGroupName, string location, string? tenant = null, RetryPolicyOptions? retryPolicy = null) + { + ValidateRequiredParameters((nameof(subscription), subscription), (nameof(resourceGroupName), resourceGroupName), (nameof(location), location)); + + try + { + var subscriptionResource = await _subscriptionService.GetSubscription(subscription, tenant, retryPolicy); + var op = await subscriptionResource.GetResourceGroups() + .CreateOrUpdateAsync(WaitUntil.Completed, resourceGroupName, new ResourceGroupData(location)) + .ConfigureAwait(false); + return op.Value; + } + catch (Exception ex) + { + throw new Exception($"Error creating or updating resource group {resourceGroupName}: {ex.Message}", ex); + } + } } diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Subscription/ISubscriptionService.cs b/core/Azure.Mcp.Core/src/Services/Azure/Subscription/ISubscriptionService.cs index 7c011abb4b..09c67cf977 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/Subscription/ISubscriptionService.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/Subscription/ISubscriptionService.cs @@ -8,9 +8,9 @@ namespace Azure.Mcp.Core.Services.Azure.Subscription; public interface ISubscriptionService { - Task> GetSubscriptions(string? tenant = null, RetryPolicyOptions? retryPolicy = null); - Task GetSubscription(string subscription, string? tenant = null, RetryPolicyOptions? retryPolicy = null); + Task> GetSubscriptions(string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); + Task GetSubscription(string subscription, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); bool IsSubscriptionId(string subscription, string? tenant = null); - Task GetSubscriptionIdByName(string subscriptionName, string? tenant = null, RetryPolicyOptions? retryPolicy = null); - Task GetSubscriptionNameById(string subscriptionId, string? tenant = null, RetryPolicyOptions? retryPolicy = null); + Task GetSubscriptionIdByName(string subscriptionName, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); + Task GetSubscriptionNameById(string subscriptionId, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default); } diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Subscription/SubscriptionService.cs b/core/Azure.Mcp.Core/src/Services/Azure/Subscription/SubscriptionService.cs index eb56e97caf..09a1df6f69 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/Subscription/SubscriptionService.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/Subscription/SubscriptionService.cs @@ -17,58 +17,58 @@ public class SubscriptionService(ICacheService cacheService, ITenantService tena private const string SubscriptionCacheKey = "subscription"; private static readonly TimeSpan s_cacheDuration = TimeSpan.FromHours(12); - public async Task> GetSubscriptions(string? tenant = null, RetryPolicyOptions? retryPolicy = null) + public async Task> GetSubscriptions(string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) { // Try to get from cache first var cacheKey = string.IsNullOrEmpty(tenant) ? CacheKey : $"{CacheKey}_{tenant}"; - var cachedResults = await _cacheService.GetAsync>(CacheGroup, cacheKey, s_cacheDuration); + var cachedResults = await _cacheService.GetAsync>(CacheGroup, cacheKey, s_cacheDuration, cancellationToken); if (cachedResults != null) { return cachedResults; } // If not in cache, fetch from Azure - var armClient = await CreateArmClientAsync(tenant, retryPolicy); + var armClient = await CreateArmClientAsync(tenant, retryPolicy, cancellationToken: cancellationToken); var subscriptions = armClient.GetSubscriptions(); var results = new List(); - await foreach (var subscription in subscriptions) + await foreach (var subscription in subscriptions.WithCancellation(cancellationToken)) { results.Add(subscription.Data); } // Cache the results - await _cacheService.SetAsync(CacheGroup, cacheKey, results, s_cacheDuration); + await _cacheService.SetAsync(CacheGroup, cacheKey, results, s_cacheDuration, cancellationToken); return results; } - public async Task GetSubscription(string subscription, string? tenant = null, RetryPolicyOptions? retryPolicy = null) + public async Task GetSubscription(string subscription, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) { - ValidateRequiredParameters(subscription); + ValidateRequiredParameters((nameof(subscription), subscription)); // Get the subscription ID first, whether the input is a name or ID - var subscriptionId = await GetSubscriptionId(subscription, tenant, retryPolicy); + var subscriptionId = await GetSubscriptionId(subscription, tenant, retryPolicy, cancellationToken); // Use subscription ID for cache key var cacheKey = string.IsNullOrEmpty(tenant) ? $"{SubscriptionCacheKey}_{subscriptionId}" : $"{SubscriptionCacheKey}_{subscriptionId}_{tenant}"; - var cachedSubscription = await _cacheService.GetAsync(CacheGroup, cacheKey, s_cacheDuration); + var cachedSubscription = await _cacheService.GetAsync(CacheGroup, cacheKey, s_cacheDuration, cancellationToken); if (cachedSubscription != null) { return cachedSubscription; } - var armClient = await CreateArmClientAsync(tenant, retryPolicy); - var response = await armClient.GetSubscriptionResource(SubscriptionResource.CreateResourceIdentifier(subscriptionId)).GetAsync(); + var armClient = await CreateArmClientAsync(tenant, retryPolicy, cancellationToken: cancellationToken); + var response = await armClient.GetSubscriptionResource(SubscriptionResource.CreateResourceIdentifier(subscriptionId)).GetAsync(cancellationToken); if (response?.Value == null) { throw new Exception($"Could not retrieve subscription {subscription}"); } // Cache the result using subscription ID - await _cacheService.SetAsync(CacheGroup, cacheKey, response.Value, s_cacheDuration); + await _cacheService.SetAsync(CacheGroup, cacheKey, response.Value, s_cacheDuration, cancellationToken); return response.Value; } @@ -78,31 +78,31 @@ public bool IsSubscriptionId(string subscription, string? tenant = null) return Guid.TryParse(subscription, out _); } - public async Task GetSubscriptionIdByName(string subscriptionName, string? tenant = null, RetryPolicyOptions? retryPolicy = null) + public async Task GetSubscriptionIdByName(string subscriptionName, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) { - var subscriptions = await GetSubscriptions(tenant, retryPolicy); + var subscriptions = await GetSubscriptions(tenant, retryPolicy, cancellationToken); var subscription = subscriptions.FirstOrDefault(s => s.DisplayName.Equals(subscriptionName, StringComparison.OrdinalIgnoreCase)) ?? throw new Exception($"Could not find subscription with name {subscriptionName}"); return subscription.SubscriptionId; } - public async Task GetSubscriptionNameById(string subscriptionId, string? tenant = null, RetryPolicyOptions? retryPolicy = null) + public async Task GetSubscriptionNameById(string subscriptionId, string? tenant = null, RetryPolicyOptions? retryPolicy = null, CancellationToken cancellationToken = default) { - var subscriptions = await GetSubscriptions(tenant, retryPolicy); + var subscriptions = await GetSubscriptions(tenant, retryPolicy, cancellationToken); var subscription = subscriptions.FirstOrDefault(s => s.SubscriptionId.Equals(subscriptionId, StringComparison.OrdinalIgnoreCase)) ?? throw new Exception($"Could not find subscription with ID {subscriptionId}"); return subscription.DisplayName; } - private async Task GetSubscriptionId(string subscription, string? tenant, RetryPolicyOptions? retryPolicy) + private async Task GetSubscriptionId(string subscription, string? tenant, RetryPolicyOptions? retryPolicy, CancellationToken cancellationToken) { if (IsSubscriptionId(subscription)) { return subscription; } - return await GetSubscriptionIdByName(subscription, tenant, retryPolicy); + return await GetSubscriptionIdByName(subscription, tenant, retryPolicy, cancellationToken); } } diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Tenant/ITenantService.cs b/core/Azure.Mcp.Core/src/Services/Azure/Tenant/ITenantService.cs index df9506bec2..c1ac23979e 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/Tenant/ITenantService.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/Tenant/ITenantService.cs @@ -1,15 +1,131 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Azure.Core; +using Azure.Mcp.Core.Services.Azure.Authentication; using Azure.ResourceManager.Resources; namespace Azure.Mcp.Core.Services.Azure.Tenant; +/// +/// Provides operations for managing Azure tenants and tenant-scoped authentication. +/// public interface ITenantService { - Task> GetTenants(); - Task GetTenantId(string tenant); - Task GetTenantIdByName(string tenantName); - Task GetTenantNameById(string tenantId); - bool IsTenantId(string tenant); + /// + /// Gets the list of all available Azure tenants. + /// + /// A token to cancel the operation. + /// + /// A task representing the asynchronous operation, with a list of + /// instances. + /// + Task> GetTenants(CancellationToken cancellationToken); + + /// + /// Gets the tenant ID from either a tenant ID or tenant name. + /// + /// The tenant ID or tenant name. + /// A cancellation token. + /// + /// A task representing the asynchronous operation, with the tenant ID or + /// if not found. + /// + /// + /// Thrown when a tenant with the specified name is not found. + /// + /// + /// Thrown when the tenant has a TenantId. + /// + Task GetTenantId(string tenantIdOrName, CancellationToken cancellationToken); + + /// + /// Gets the tenant ID by tenant name. + /// + /// The tenant name. + /// A cancellation token. + /// + /// A task representing the asynchronous operation, with the tenant ID or + /// if not found. + /// + /// + /// Thrown when a tenant with the specified name is not found. + /// + /// + /// Thrown when the tenant has a TenantId. + /// + Task GetTenantIdByName(string tenantName, CancellationToken cancellationToken); + + /// + /// Gets the tenant name by tenant ID. + /// + /// The tenant ID. + /// A cancellation token. + /// + /// A task representing the asynchronous operation, with the tenant name or if not found. + /// + /// + /// Thrown when a tenant with the specified ID is not found. + /// + /// + /// Thrown when the tenant has a DisplayName. + /// + Task GetTenantNameById(string tenantId, CancellationToken cancellationToken); + + /// + /// Determines whether the specified string is a valid tenant ID (GUID format). + /// + /// The string to validate. + /// + /// if the string is a valid tenant ID; otherwise, . + /// + bool IsTenantId(string tenantId); + + /// + /// Gets an instance of . + /// + /// Optional tenant ID. Use in most cases. + /// A cancellation token. + /// + /// A task representing the asynchronous operation, with a value of . + /// + /// + /// Implementors of this method must use to obtain + /// the token credential. + /// + /// + /// Thrown when the operation has been cancelled. + /// + /// + /// Thrown when a credential cannot be provided. + /// + Task GetTokenCredentialAsync( + string? tenantId, + CancellationToken cancellationToken); + + /// + /// Gets a new instance of configured for use with Azure tenant operations. + /// + /// + /// Each instance includes the following configuration: + /// + /// Proxy settings + /// Record/playback handler + /// Timeout configuration + /// User-Agent header + /// + /// Do: + /// + /// Utilize the client for a single method or MCP tool invocation. + /// Add request-specific configuration that is scoped to the current operation. + /// + /// Don't: + /// + /// Persist the client beyond the lifetime of the invoking tool. + /// + /// + /// + /// An instance configured for use with Azure tenant operations. + /// + HttpClient GetClient(); } diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantService.cs b/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantService.cs index 1dc07687ef..aca445a809 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantService.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantService.cs @@ -1,24 +1,40 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Azure.Core; +using Azure.Core.Pipeline; +using Azure.Mcp.Core.Services.Azure.Authentication; using Azure.Mcp.Core.Services.Caching; using Azure.ResourceManager; using Azure.ResourceManager.Resources; namespace Azure.Mcp.Core.Services.Azure.Tenant; -public class TenantService(ICacheService cacheService) - : BaseAzureService, ITenantService +public class TenantService : BaseAzureService, ITenantService { - private readonly ICacheService _cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService)); + private readonly IAzureTokenCredentialProvider _credentialProvider; + private readonly ICacheService _cacheService; + private readonly IHttpClientFactory _httpClientFactory; private const string CacheGroup = "tenant"; private const string CacheKey = "tenants"; private static readonly TimeSpan s_cacheDuration = TimeSpan.FromHours(12); - public async Task> GetTenants() + public TenantService( + IAzureTokenCredentialProvider credentialProvider, + ICacheService cacheService, + IHttpClientFactory clientFactory) + { + _credentialProvider = credentialProvider; + _cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService)); + _httpClientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory)); + TenantService = this; + } + + /// + public async Task> GetTenants(CancellationToken cancellationToken) { // Try to get from cache first - var cachedResults = await _cacheService.GetAsync>(CacheGroup, CacheKey, s_cacheDuration); + var cachedResults = await _cacheService.GetAsync>(CacheGroup, CacheKey, s_cacheDuration, cancellationToken); if (cachedResults != null) { return cachedResults; @@ -28,7 +44,8 @@ public async Task> GetTenants() var results = new List(); var options = AddDefaultPolicies(new ArmClientOptions()); - var client = new ArmClient(await GetCredential(), default, options); + options.Transport = new HttpClientTransport(GetClient()); + var client = new ArmClient(await GetCredential(cancellationToken), default, options); await foreach (var tenant in client.GetTenants()) { @@ -36,46 +53,64 @@ public async Task> GetTenants() } // Cache the results - await _cacheService.SetAsync(CacheGroup, CacheKey, results, s_cacheDuration); + await _cacheService.SetAsync(CacheGroup, CacheKey, results, s_cacheDuration, cancellationToken); return results; } - public bool IsTenantId(string tenant) + /// + public bool IsTenantId(string tenantId) { - return Guid.TryParse(tenant, out _); + return Guid.TryParse(tenantId, out _); } - public async Task GetTenantId(string tenant) + /// + public async Task GetTenantId(string tenantIdOrName, CancellationToken cancellationToken) { - if (IsTenantId(tenant)) + if (IsTenantId(tenantIdOrName)) { - return tenant; + return tenantIdOrName; } - return await GetTenantIdByName(tenant); + return await GetTenantIdByName(tenantIdOrName, cancellationToken); } - public async Task GetTenantIdByName(string tenantName) + /// + public async Task GetTenantIdByName(string tenantName, CancellationToken cancellationToken) { - var tenants = await GetTenants(); + var tenants = await GetTenants(cancellationToken); var tenant = tenants.FirstOrDefault(t => t.Data.DisplayName?.Equals(tenantName, StringComparison.OrdinalIgnoreCase) == true) ?? throw new Exception($"Could not find tenant with name {tenantName}"); - if (tenant.Data.TenantId == null) + string? tenantId = tenant.Data.TenantId?.ToString(); + if (tenantId == null) throw new InvalidOperationException($"Tenant {tenantName} has a null TenantId"); - return tenant.Data.TenantId.ToString(); + return tenantId.ToString(); } - public async Task GetTenantNameById(string tenantId) + /// + public async Task GetTenantNameById(string tenantId, CancellationToken cancellationToken) { - var tenants = await GetTenants(); + var tenants = await GetTenants(cancellationToken); var tenant = tenants.FirstOrDefault(t => t.Data.TenantId?.ToString().Equals(tenantId, StringComparison.OrdinalIgnoreCase) == true) ?? throw new Exception($"Could not find tenant with ID {tenantId}"); - if (tenant.Data.DisplayName == null) + string? tenantName = tenant.Data.DisplayName; + if (tenantName == null) throw new InvalidOperationException($"Tenant with ID {tenantId} has a null DisplayName"); - return tenant.Data.DisplayName; + return tenantName; + } + + /// + public async Task GetTokenCredentialAsync(string? tenantId, CancellationToken cancellationToken) + { + return await _credentialProvider.GetTokenCredentialAsync(tenantId, cancellationToken); + } + + /// + public HttpClient GetClient() + { + return _httpClientFactory.CreateClient(); } } diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantServiceCollectionExtensions.cs b/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantServiceCollectionExtensions.cs new file mode 100644 index 0000000000..6a14cb9eb4 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantServiceCollectionExtensions.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Services.Azure.Authentication; +using Azure.Mcp.Core.Services.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Azure.Mcp.Core.Services.Azure.Tenant; + +/// +/// Extension methods for configuring Azure tenant services. +/// +public static class TenantServiceCollectionExtensions +{ + /// + /// Adds as with lifetime + /// into the service collection. + /// + /// The service collection. + /// The service collection. + /// + /// + /// This method follows the dependency graph pattern by ensuring all dependencies of + /// are registered by calling their respective extension methods. + /// + /// + /// Dependencies registered: + /// + /// + /// + /// + /// via . + /// This can be overridden using + /// based on parsed command line arguments and environment variables. + /// + /// + /// + /// + public static IServiceCollection AddAzureTenantService(this IServiceCollection services, bool addUserAgentClient = false) + { + // !!! HACK !!! + // Program.cs for the CLI servers have their own DI containers vs ServiceStartCommand. + // If the CLI is started to run a non-"server start" command, then we're assuming we should + // use the identity resolved from the host environment for downstream auth (e.g., Azure CLI + // or VS Code user). This will fulfill the DI container for those non-"server start" commands. + // Within ServiceStartCommand, when it has its own fully IHost with DI and IConfiguration, + // then it will need to call AddAzureTokenCredentialProvider for just the ServiceStartCommand + // container to be populated with the correct authentication strategy, such as OBO for + // running as a remote HTTP MCP service. + services.AddSingleIdentityTokenCredentialProvider(); + + services.AddHttpClient(); + if (addUserAgentClient) + { + services.ConfigureDefaultHttpClient(); + } + + services.TryAddSingleton(); + return services; + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Caching/CachingServiceCollectionExtensions.cs b/core/Azure.Mcp.Core/src/Services/Caching/CachingServiceCollectionExtensions.cs new file mode 100644 index 0000000000..ca838c81e0 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Caching/CachingServiceCollectionExtensions.cs @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Azure.Mcp.Core.Services.Caching; + +/// +/// Extension methods for configuring cache services. +/// +public static class CachingServiceCollectionExtensions +{ + /// + /// Adds as an with lifetime + /// into the service collection. + /// + /// The service collection. + /// The service collection. + /// + /// + /// This method registers the single-user CLI cache service which is appropriate for + /// single-user command-line scenarios where all cached data belongs to a single user. + /// + /// + /// This method will not override any existing registration. + /// It can be overridden as needed by specific configurations. + /// + /// + public static IServiceCollection AddSingleUserCliCacheService(this IServiceCollection services) + { + services.TryAddSingleton(); + return services; + } + + /// + /// Adds as an with lifetime + /// into the service collection. + /// + /// The service collection. + /// The service collection. + /// + /// + /// This method registers the HTTP service cache service which is appropriate for + /// multi-user web API scenarios where cached data must be partitioned by user. + /// + /// + /// This method will override any existing registration. + /// This is unlike . + /// + /// + public static IServiceCollection AddHttpServiceCacheService(this IServiceCollection services) + { + services.AddSingleton(); + return services; + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Caching/HttpServiceCacheService.cs b/core/Azure.Mcp.Core/src/Services/Caching/HttpServiceCacheService.cs new file mode 100644 index 0000000000..4791da28be --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Caching/HttpServiceCacheService.cs @@ -0,0 +1,50 @@ +namespace Azure.Mcp.Core.Services.Caching; + +/// +/// An implementation of for multi-user web API scenarios. +/// +/// A memory cache. +/// +/// +/// Do not instantiate directly. Use . +/// +/// +/// For single-user CLI scenarios, use . +/// +/// +// TODO implement this with IHttpContextAccessor + IMemoryCache. This is a no-op stub for now +// until we decide how to handle this. For example, do we only cache within a request rather +// than across requests by the same user? What are the implication of advanced Entra features +// like Conditional Access on caching? +public class HttpServiceCacheService : ICacheService +{ + public ValueTask ClearAsync(CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + public ValueTask ClearGroupAsync(string group, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + public ValueTask DeleteAsync(string group, string key, CancellationToken cancellationToken) + { + return ValueTask.CompletedTask; + } + + public ValueTask GetAsync(string group, string key, TimeSpan? expiration = null, CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(default); + } + + public ValueTask> GetGroupKeysAsync(string group, CancellationToken cancellationToken) + { + return ValueTask.FromResult>(Array.Empty()); + } + + public ValueTask SetAsync(string group, string key, T data, TimeSpan? expiration = null, CancellationToken cancellationToken = default) + { + return ValueTask.CompletedTask; + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Caching/ICacheService.cs b/core/Azure.Mcp.Core/src/Services/Caching/ICacheService.cs index 5ddfdb61c9..3b325a37b2 100644 --- a/core/Azure.Mcp.Core/src/Services/Caching/ICacheService.cs +++ b/core/Azure.Mcp.Core/src/Services/Caching/ICacheService.cs @@ -12,8 +12,9 @@ public interface ICacheService /// The group name. /// The cache key within the group. /// Optional expiration time. + /// A token to cancel the operation. /// The cached value or default if not found. - ValueTask GetAsync(string group, string key, TimeSpan? expiration = null); + ValueTask GetAsync(string group, string key, TimeSpan? expiration = null, CancellationToken cancellationToken = default); /// /// Sets a value in the cache using a group and key. @@ -23,32 +24,39 @@ public interface ICacheService /// The cache key within the group. /// The data to cache. /// Optional expiration time. - ValueTask SetAsync(string group, string key, T data, TimeSpan? expiration = null); + /// A token to cancel the operation. + /// A ValueTask representing the asynchronous operation. + ValueTask SetAsync(string group, string key, T data, TimeSpan? expiration = null, CancellationToken cancellationToken = default); /// /// Deletes a value from the cache using a group and key. /// /// The group name. /// The cache key within the group. - ValueTask DeleteAsync(string group, string key); + /// A token to cancel the operation. + /// A ValueTask representing the asynchronous operation. + ValueTask DeleteAsync(string group, string key, CancellationToken cancellationToken); /// /// Gets all keys in a specific group. /// /// The group name. + /// A token to cancel the operation. /// A collection of keys in the specified group. - ValueTask> GetGroupKeysAsync(string group); + ValueTask> GetGroupKeysAsync(string group, CancellationToken cancellationToken); /// /// Clears all items from the cache. /// + /// A token to cancel the operation. /// A ValueTask representing the asynchronous operation. - ValueTask ClearAsync(); + ValueTask ClearAsync(CancellationToken cancellationToken); /// /// Clears all items from a specific group in the cache. /// /// The group name to clear. + /// A token to cancel the operation. /// A ValueTask representing the asynchronous operation. - ValueTask ClearGroupAsync(string group); + ValueTask ClearGroupAsync(string group, CancellationToken cancellationToken); } diff --git a/core/Azure.Mcp.Core/src/Services/Caching/CacheService.cs b/core/Azure.Mcp.Core/src/Services/Caching/SingleUserCliCacheService.cs similarity index 72% rename from core/Azure.Mcp.Core/src/Services/Caching/CacheService.cs rename to core/Azure.Mcp.Core/src/Services/Caching/SingleUserCliCacheService.cs index 7d6ff38f87..8a9942a44c 100644 --- a/core/Azure.Mcp.Core/src/Services/Caching/CacheService.cs +++ b/core/Azure.Mcp.Core/src/Services/Caching/SingleUserCliCacheService.cs @@ -6,18 +6,30 @@ namespace Azure.Mcp.Core.Services.Caching; -public class CacheService(IMemoryCache memoryCache) : ICacheService +/// +/// An implementation of for single-user CLI scenarios using in-memory caching. +/// +/// A memory cache. +/// +/// +/// Do not instantiate directly. Use . +/// +/// +/// For multi-user web API scenarios, use . +/// +/// +public class SingleUserCliCacheService(IMemoryCache memoryCache) : ICacheService { private readonly IMemoryCache _memoryCache = memoryCache; private static readonly ConcurrentDictionary> s_groupKeys = new(); - public ValueTask GetAsync(string group, string key, TimeSpan? expiration = null) + public ValueTask GetAsync(string group, string key, TimeSpan? expiration = null, CancellationToken cancellationToken = default) { string cacheKey = GetGroupKey(group, key); return _memoryCache.TryGetValue(cacheKey, out T? value) ? new ValueTask(value) : default; } - public ValueTask SetAsync(string group, string key, T data, TimeSpan? expiration = null) + public ValueTask SetAsync(string group, string key, T data, TimeSpan? expiration = null, CancellationToken cancellationToken = default) { if (data == null) return default; @@ -44,7 +56,7 @@ public ValueTask SetAsync(string group, string key, T data, TimeSpan? expirat return default; } - public ValueTask DeleteAsync(string group, string key) + public ValueTask DeleteAsync(string group, string key, CancellationToken cancellationToken) { string cacheKey = GetGroupKey(group, key); _memoryCache.Remove(cacheKey); @@ -58,7 +70,7 @@ public ValueTask DeleteAsync(string group, string key) return default; } - public ValueTask> GetGroupKeysAsync(string group) + public ValueTask> GetGroupKeysAsync(string group, CancellationToken cancellationToken) { if (s_groupKeys.TryGetValue(group, out var keys)) { @@ -68,7 +80,7 @@ public ValueTask> GetGroupKeysAsync(string group) return new ValueTask>([]); } - public ValueTask ClearAsync() + public ValueTask ClearAsync(CancellationToken cancellationToken) { // Clear all items from the memory cache if (_memoryCache is MemoryCache memoryCache) @@ -82,7 +94,7 @@ public ValueTask ClearAsync() return default; } - public ValueTask ClearGroupAsync(string group) + public ValueTask ClearGroupAsync(string group, CancellationToken cancellationToken) { // If this group doesn't exist, nothing to do if (!s_groupKeys.TryGetValue(group, out var keys)) diff --git a/core/Azure.Mcp.Core/src/Services/Http/HttpClientFactoryConfigurator.cs b/core/Azure.Mcp.Core/src/Services/Http/HttpClientFactoryConfigurator.cs new file mode 100644 index 0000000000..81f2ec3269 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Http/HttpClientFactoryConfigurator.cs @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Runtime.Versioning; +using Azure.Mcp.Core.Areas.Server.Options; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http; +using Microsoft.Extensions.Options; + +namespace Azure.Mcp.Core.Services.Http; + +public static class HttpClientFactoryConfigurator +{ + private static readonly string s_version; + private static readonly string s_framework; + private static readonly string s_platform; + + private static string? s_userAgent = null; + + static HttpClientFactoryConfigurator() + { + var assembly = typeof(HttpClientService).Assembly; + s_version = assembly.GetCustomAttribute()?.Version ?? "unknown"; + s_framework = assembly.GetCustomAttribute()?.FrameworkName ?? "unknown"; + s_platform = RuntimeInformation.OSDescription; + } + + public static IServiceCollection ConfigureDefaultHttpClient( + this IServiceCollection services, + Func? recordingProxyResolver = null) + { + ArgumentNullException.ThrowIfNull(services); + + services.ConfigureHttpClientDefaults(builder => ConfigureHttpClientBuilder(builder, recordingProxyResolver)); + + return services; + } + + private static void ConfigureHttpClientBuilder(IHttpClientBuilder builder, Func? recordingProxyResolver) + { + builder.ConfigureHttpClient((serviceProvider, client) => + { + var httpClientOptions = serviceProvider.GetRequiredService>().Value; + client.Timeout = httpClientOptions.DefaultTimeout; + + var transport = serviceProvider.GetRequiredService>().Value.Transport; + client.DefaultRequestHeaders.UserAgent.ParseAdd(BuildUserAgent(transport)); + }); + + builder.ConfigurePrimaryHttpMessageHandler(serviceProvider => CreateHttpMessageHandler(serviceProvider, recordingProxyResolver)); + } + + private static HttpMessageHandler CreateHttpMessageHandler(IServiceProvider serviceProvider, Func? recordingProxyResolver) + { + var options = serviceProvider.GetRequiredService>().Value; + var handler = new HttpClientHandler(); + + var proxy = CreateProxy(options); + if (proxy != null) + { + handler.Proxy = proxy; + handler.UseProxy = true; + } + +#if DEBUG + var proxyUri = ResolveRecordingProxy(recordingProxyResolver); + if (proxyUri != null) + { + return new RecordingRedirectHandler(proxyUri) + { + InnerHandler = handler + }; + } +#endif + + return handler; + } + +#if DEBUG + /// + /// This function will only ever run in debug mode. It resolves the recording proxy URI either from from either a provided resolver function + /// or the TEST_PROXY_URL environment variable. This is necessary for livetest scenarios that directly invoke a service rather than going through CallToolAsync(), + /// as scenarios like this require that the proxy be set up at the ClientFactory level, where globally set environment variables would break other tests running in parallel. + /// + /// See for more details on how the recording proxy function is provided. + /// + /// Optional function that will resolve a proxy uri. + /// + /// + private static Uri? ResolveRecordingProxy(Func? recordingProxyResolver) + { + Uri? proxyUri = null; + + if (recordingProxyResolver != null) + { + proxyUri = recordingProxyResolver(); + if (proxyUri != null && !proxyUri.IsAbsoluteUri) + { + throw new InvalidOperationException("Recording proxy resolver must return an absolute URI."); + } + } + + if (proxyUri == null) + { + var testProxyUrl = Environment.GetEnvironmentVariable("TEST_PROXY_URL"); + if (!string.IsNullOrWhiteSpace(testProxyUrl) && Uri.TryCreate(testProxyUrl, UriKind.Absolute, out var envProxy)) + { + proxyUri = envProxy; + } + } + + return proxyUri; + } +#endif + + private static WebProxy? CreateProxy(HttpClientOptions options) + { + string? proxyAddress = options.AllProxy ?? options.HttpsProxy ?? options.HttpProxy; + + if (string.IsNullOrEmpty(proxyAddress)) + { + return null; + } + + if (!Uri.TryCreate(proxyAddress, UriKind.Absolute, out var proxyUri)) + { + return null; + } + + var proxy = new WebProxy(proxyUri); + + if (!string.IsNullOrEmpty(options.NoProxy)) + { + var bypassList = options.NoProxy + .Split(',', StringSplitOptions.RemoveEmptyEntries) + .Select(s => s.Trim()) + .Where(s => !string.IsNullOrEmpty(s)) + .Select(ConvertGlobToRegex) + .ToArray(); + + if (bypassList.Length > 0) + { + proxy.BypassList = bypassList; + } + } + + return proxy; + } + + private static string ConvertGlobToRegex(string globPattern) + { + if (string.IsNullOrEmpty(globPattern)) + { + return string.Empty; + } + + var escaped = globPattern + .Replace("\\", "\\\\") + .Replace(".", "\\.") + .Replace("+", "\\+") + .Replace("$", "\\$") + .Replace("^", "\\^") + .Replace("{", "\\{") + .Replace("}", "\\}") + .Replace("[", "\\[") + .Replace("]", "\\]") + .Replace("(", "\\(") + .Replace(")", "\\)") + .Replace("|", "\\|"); + + var regex = escaped + .Replace("*", ".*") + .Replace("?", "."); + + return $"^{regex}$"; + } + + private static string BuildUserAgent(string transport) + { + s_userAgent ??= $"azmcp/{s_version} azmcp-{transport}/{s_version} ({s_framework}; {s_platform})"; + return s_userAgent; + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Http/HttpClientOptions.cs b/core/Azure.Mcp.Core/src/Services/Http/HttpClientOptions.cs index b23478bdcd..97314600d7 100644 --- a/core/Azure.Mcp.Core/src/Services/Http/HttpClientOptions.cs +++ b/core/Azure.Mcp.Core/src/Services/Http/HttpClientOptions.cs @@ -34,7 +34,10 @@ public sealed class HttpClientOptions public TimeSpan DefaultTimeout { get; set; } = TimeSpan.FromSeconds(100); /// - /// Gets or sets the default User-Agent header value. + /// Gets or sets the default User-Agent header value. /// + /// + /// UNUSED: This overwrites all user agents for all HTTP clients. So, it's not recommended to use this. + /// public string? DefaultUserAgent { get; set; } } diff --git a/core/Azure.Mcp.Core/src/Services/Http/HttpClientService.cs b/core/Azure.Mcp.Core/src/Services/Http/HttpClientService.cs index b0c58e54fc..8013574202 100644 --- a/core/Azure.Mcp.Core/src/Services/Http/HttpClientService.cs +++ b/core/Azure.Mcp.Core/src/Services/Http/HttpClientService.cs @@ -1,7 +1,11 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.ClientModel.Primitives; using System.Net; +using System.Reflection; +using System.Runtime.Versioning; +using Azure.Mcp.Core.Areas.Server.Options; using Microsoft.Extensions.Options; namespace Azure.Mcp.Core.Services.Http; @@ -14,11 +18,20 @@ public sealed class HttpClientService : IHttpClientService, IDisposable private readonly HttpClientOptions _options; private readonly Lazy _defaultClient; private bool _disposed; + private string UserAgent { get; } - public HttpClientService(IOptions options) + public HttpClientService(IOptions options, IOptions serviceStartOptions) { _options = options?.Value ?? throw new ArgumentNullException(nameof(options)); _defaultClient = new Lazy(() => CreateClientInternal()); + + var assembly = typeof(HttpClientService).Assembly; + var version = assembly.GetCustomAttribute()?.Version ?? "unknown"; + var framework = assembly.GetCustomAttribute()?.FrameworkName ?? "unknown"; + var platform = System.Runtime.InteropServices.RuntimeInformation.OSDescription; + + var transport = serviceStartOptions?.Value.Transport ?? TransportTypes.StdIo; + UserAgent = $"azmcp/{version} azmcp-{transport}/{version} ({framework}; {platform})"; } /// @@ -53,15 +66,30 @@ public HttpClient CreateClient(Uri? baseAddress, Action configureCli private HttpClient CreateClientInternal() { var handler = CreateHttpClientHandler(); + +#if DEBUG + // If a TEST_PROXY_URL is configured, insert RecordingRedirectHandler as the last delegating handler + var testProxyUrl = Environment.GetEnvironmentVariable("TEST_PROXY_URL"); + Console.WriteLine("Using test proxy URL: " + testProxyUrl); + HttpMessageHandler pipeline = handler; + if (!string.IsNullOrWhiteSpace(testProxyUrl) && Uri.TryCreate(testProxyUrl, UriKind.Absolute, out var proxyUri)) + { + Console.WriteLine("Inserting RecordingRedirectHandler for test proxy."); + // RecordingRedirectHandler should be the last delegating handler before the transport + pipeline = new RecordingRedirectHandler(proxyUri) + { + InnerHandler = pipeline + }; + } + var client = new HttpClient(pipeline); +#else var client = new HttpClient(handler); +#endif // Apply default configuration client.Timeout = _options.DefaultTimeout; - if (!string.IsNullOrEmpty(_options.DefaultUserAgent)) - { - client.DefaultRequestHeaders.UserAgent.ParseAdd(_options.DefaultUserAgent); - } + client.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent); return client; } diff --git a/core/Azure.Mcp.Core/src/Services/Http/RecordingRedirectHandler.cs b/core/Azure.Mcp.Core/src/Services/Http/RecordingRedirectHandler.cs new file mode 100644 index 0000000000..79551d3495 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Services/Http/RecordingRedirectHandler.cs @@ -0,0 +1,58 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net.Http.Headers; + +namespace Azure.Mcp.Core.Services.Http; + +/// +/// DelegatingHandler that rewrites outgoing requests to a recording/replace proxy specified by TEST_PROXY_URL. +/// It also sets the x-recording-upstream-base-uri header once per request to preserve the original target. +/// +/// This handler is intended to be injected as the LAST delegating handler (closest to the transport) so +/// that it rewrites the final outgoing wire request. +/// +internal sealed class RecordingRedirectHandler : DelegatingHandler +{ + private readonly Uri _proxyUri; + + public RecordingRedirectHandler(Uri proxyUri) + { + _proxyUri = proxyUri ?? throw new ArgumentNullException(nameof(proxyUri)); + } + + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + { + Redirect(request); + return base.Send(request, cancellationToken)!; + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Redirect(request); + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } + + private void Redirect(HttpRequestMessage message) + { + // Only set upstream header once (HttpRequestMessage can be cloned/reused by some handlers) + if (!message.Headers.Contains("x-recording-upstream-base-uri")) + { + var upstream = new UriBuilder(message.RequestUri!) + { + Query = string.Empty, + Path = string.Empty + }; + message.Headers.Add("x-recording-upstream-base-uri", upstream.Uri.ToString()); + } + + // Rewrite target host/scheme/port + var builder = new UriBuilder(_proxyUri) + { + Path = message.RequestUri!.AbsolutePath, + Query = message.RequestUri!.Query?.TrimStart('?') ?? string.Empty + }; + + message.RequestUri = builder.Uri; + } +} diff --git a/core/Azure.Mcp.Core/src/Services/ProcessExecution/ExternalProcessService.cs b/core/Azure.Mcp.Core/src/Services/ProcessExecution/ExternalProcessService.cs index d2ae922284..eb93788bc1 100644 --- a/core/Azure.Mcp.Core/src/Services/ProcessExecution/ExternalProcessService.cs +++ b/core/Azure.Mcp.Core/src/Services/ProcessExecution/ExternalProcessService.cs @@ -3,135 +3,730 @@ using System.Diagnostics; using System.Text; +using System.Text.Json; using System.Text.Json.Serialization; +using Microsoft.Extensions.Logging; +using static Azure.Mcp.Core.Services.ProcessExecution.ProcessExtensions; namespace Azure.Mcp.Core.Services.ProcessExecution; -public class ExternalProcessService : IExternalProcessService +/// +/// Executes external processes and captures their output in a timeout and cancellation aware way. +/// +public class ExternalProcessService(ILogger logger) : IExternalProcessService { - private readonly Dictionary environmentVariables = []; - + /// + /// Executes an external process and captures its stdout and stderr. + /// + /// The name or path of the executable to run. In the case of a bare executable name, + /// the operating system resolves the executable using its standard search rules (including directories + /// listed in the PATH environment variable). + /// Command-line arguments to pass to the process. + /// + /// Optional environment variables. If null, the process inherits the parent environment. + /// + /// + /// Total timeout for the operation (process execution + exit-wait + stdout/stderr drain). + /// Must be greater than zero. + /// + /// + /// External cancellation token. When triggered, the process is terminated on a best-effort basis + /// and is rethrown. + /// + /// + /// A containing the exit code, stdout, stderr, and the full command. + /// + /// + /// Thrown when is less than or equal to zero. + /// + /// + /// Thrown when is null or empty. + /// + /// + /// Thrown when the process fails to start. + /// + /// + /// Thrown when the operation (process execution and stream draining) exceeds . + /// + /// + /// Thrown when is triggered before the operation completes. + /// + /// + /// Timeout vs. cancellation: + /// + /// Timeout and cancellation are handled independently: + /// + /// + /// + /// + /// is enforced by wrapping the combined + /// (exit-wait + stdout + stderr) task in . + /// A timeout always results in . + /// If the timeout occurs before the process has exited, a best-effort attempt is made to + /// terminate the process (and its tree) before throwing. + /// + /// + /// + /// + /// represents external caller cancellation. When cancellation is + /// signaled, and the process has not yet exited, a best-effort attempt is made to terminate the process + /// (and its tree) before rethrowing the original . + /// + /// + /// + /// + /// This separation avoids the common ambiguity where timeouts are implemented by canceling the caller’s token. + /// Here: always means timeout; + /// always means external cancellation. + /// + /// + /// + /// Diagnostic information on exceptions:
+ /// When a timeout or external cancellation occurs, this method attaches structured diagnostic + /// information to the thrown or + /// via the exception’s Data dictionary. This + /// information does not alter exception semantics or stack traces, and exists solely to aid + /// debugging. The following fields are always included: + ///
+ /// + /// + /// "ProcessName" — the process name if available, or "<unknown>". + /// "ProcessId" — the process ID if available, or -1. + /// "ProcessExitStatus" — one of Exited, NotExited, or Indeterminate, + /// indicating the observed exit state of the process "before" any kill attempt. + /// + /// + /// + /// Additional diagnostic fields are included only when relevant: + /// + /// + /// + /// + /// + /// "ProcessKillException" — present only when the process was observed to be + /// NotExited and a best-effort attempt to terminate it + /// (via ) failed. The value is the caught exception. + /// + /// + /// + /// + /// "ProcessExitCheckException" — present only when the process exit state could not be + /// determined because itself threw during the exit check. This + /// captures the original exception that made the exit status Indeterminate. + /// + /// + /// + /// + /// + /// These fields are useful for diagnosing cases such as processes failing to terminate, OS-level handle + /// failures, corrupted process state, or unexpected platform behavior. All diagnostic data is attached + /// without modifying the original exception type, message, stack trace, or cancellation semantics. + /// + /// + /// + /// The method does not interpret success or failure using . + /// The exit code is included in the , and the caller decides how to interpret it. + /// + ///
+ /// public async Task ExecuteAsync( - string executablePath, + string fileName, string arguments, - int timeoutSeconds = 300, - IEnumerable? customPaths = null) + IDictionary? environmentVariables = null, + int operationTimeoutSeconds = 300, + CancellationToken cancellationToken = default) { - ArgumentException.ThrowIfNullOrEmpty(executablePath); + var operationTimeout = ValidateTimeout(operationTimeoutSeconds); + + using Process process = CreateProcess(fileName, arguments, environmentVariables); + using ProcessStreamReader stdoutReader = new(process, isErrorStream: false, logger); + using ProcessStreamReader stderrReader = new(process, isErrorStream: true, logger); - if (!File.Exists(executablePath)) + if (!process.Start()) { - throw new FileNotFoundException($"Executable not found at path: {executablePath}"); + throw new InvalidOperationException($"Failed to start process: {fileName}"); } - var processStartInfo = new ProcessStartInfo + Task stdoutTask = stdoutReader.ReadToEndAsync(); + Task stderrTask = stderrReader.ReadToEndAsync(); + Task exitTask = process.WaitForExitAsync(cancellationToken); + + Task operation = Task.WhenAll(exitTask, stdoutTask, stderrTask); + + try { - FileName = executablePath, + await operation.WaitAsync(operationTimeout, cancellationToken).ConfigureAwait(false); + } + catch (TimeoutException) + { + // Timeout may be thrown either: + // - Case A: before the process had exited, or + // - Case B: after the process had already exited, but before streams were fully drained. + throw HandleTimeout(process, operationTimeout, fileName, arguments); + } + catch (OperationCanceledException oce) when (cancellationToken.IsCancellationRequested) + { + // Cancellation was explicitly requested by the caller (not a timeout). + // OCE may be thrown either: + // - Case A: by WaitForExitAsync while the process is still running, or + // - Case B: by WaitAsync after the process has already exited. + HandleCancellation(process, fileName, arguments, oce); + // 'throw;' here preserves the original stack trace from where the OCE was first thrown, + // inside WaitAsync or WaitForExitAsync not from this catch block. + throw; + } + + // The earlier await on Task.WhenAll(...) guarantees that stdoutTask and stderrTask have already run + // to completion. The .Result simply retrieves their already-computed output without any blocking. + var stdout = stdoutTask.Result.TrimEnd(); + var stderr = stderrTask.Result.TrimEnd(); + + // Normal completion: the process has exited, and stdout/stderr have fully drained. + return new ProcessResult( + process.ExitCode, + stdout, + stderr, + $"{fileName} {arguments}"); + + // The `using` declarations at the top ensure that both ProcessStreamReader and Process are disposed on + // every exit path—normal completion, timeout, or cancellation — unsubscribing handlers and releasing OS + // resources. + } + + public JsonElement ParseJsonOutput(ProcessResult result) + { + if (result.ExitCode != 0) + { + var error = new ParseError( + result.ExitCode, + result.Error, + result.Command); + + return JsonSerializer.SerializeToElement( + error, + ServicesJsonContext.Default.ParseError); + } + + try + { + using var jsonDocument = JsonDocument.Parse(result.Output); + return jsonDocument.RootElement.Clone(); + } + catch + { + var fallback = new ParseOutput(result.Output); + return JsonSerializer.SerializeToElement( + fallback, + ServicesJsonContext.Default.ParseOutput); + } + } + + internal record ParseError( + int ExitCode, + string Error, + string Command); + + internal record ParseOutput( + [property: JsonPropertyName("output")] + string Output); + + private static TimeSpan ValidateTimeout(int operationTimeoutSeconds) + { + if (operationTimeoutSeconds <= 0) + { + throw new ArgumentOutOfRangeException(nameof(operationTimeoutSeconds), "Timeout must be a positive number of seconds."); + } + return TimeSpan.FromSeconds(operationTimeoutSeconds); + } + + /// + /// Creates and configures a for execution with redirected stdout/stderr/stdin and + /// applied environment variables. + /// + private static Process CreateProcess(string fileName, string arguments, IDictionary? environmentVariables) + { + ArgumentException.ThrowIfNullOrEmpty(fileName); + + var startInfo = new ProcessStartInfo + { + FileName = fileName, Arguments = arguments, + RedirectStandardOutput = true, RedirectStandardError = true, RedirectStandardInput = true, + UseShellExecute = false, CreateNoWindow = true, StandardOutputEncoding = Encoding.UTF8, StandardErrorEncoding = Encoding.UTF8 }; - foreach (var keyValuePair in environmentVariables) + if (environmentVariables is not null) + { + foreach (var pair in environmentVariables) + { + startInfo.EnvironmentVariables[pair.Key] = pair.Value; + } + } + + return new Process + { + StartInfo = startInfo, + EnableRaisingEvents = true + }; + } + + private TimeoutException HandleTimeout(Process process, TimeSpan timeout, string executablePath, string arguments) + { + // Get the pre-kill exit state and any kill error (if termination was attempted). + (ExitCheckResult exitCheck, Exception? killException) = process.TryKill(logger); + string command = $"{executablePath} {arguments}"; + + TimeoutException exception; + + switch (exitCheck.Status) { - processStartInfo.EnvironmentVariables[keyValuePair.Key] = keyValuePair.Value; + case ExitStatus.NotExited: + // Timeout occurred before the process had exited: the process itself exceeded the timeout. + exception = new TimeoutException($"Process execution timed out after {timeout.TotalSeconds} seconds: {command}"); + if (killException is not null) + { + exception.Data["ProcessKillException"] = killException; + logger.LogWarning(killException, "Failed to kill process after timeout for command: {Command}", command); + } + break; + + case ExitStatus.Exited: + // Timeout occurred after the process had already exited, but before streams were fully drained. + exception = new TimeoutException($"Process streams draining timed out after {timeout.TotalSeconds} seconds: {command}"); + break; + + case ExitStatus.Indeterminate: + // Could not determine exit state due to an exception from Process.HasExited (no kill was attempted). + exception = new TimeoutException($"Process execution or streams draining timed out after {timeout.TotalSeconds} seconds: {command}"); + exception.Data["ProcessExitCheckException"] = exitCheck.CheckException; + logger.LogWarning(exitCheck.CheckException, "Could not determine process exit state after the timeout for command: {Command}", command); + break; + + default: + throw new InvalidOperationException($"Unexpected exit status: {exitCheck.Status}"); } - using var process = new Process { StartInfo = processStartInfo }; - using var outputWaitHandle = new AutoResetEvent(false); - using var errorWaitHandle = new AutoResetEvent(false); + exception.Data["ProcessName"] = process.SafeName(); + exception.Data["ProcessId"] = process.SafeId(); + exception.Data["ProcessExitStatus"] = exitCheck.Status.ToString(); - var outputBuilder = new StringBuilder(); - var errorBuilder = new StringBuilder(); + return exception; + } + + private void HandleCancellation(Process process, string executablePath, string arguments, OperationCanceledException exception) + { + // Get the pre-kill exit state and any kill error (if termination was attempted). + (ExitCheckResult exitCheck, Exception? killException) = process.TryKill(logger); + string command = $"{executablePath} {arguments}"; - process.OutputDataReceived += (sender, e) => + switch (exitCheck.Status) { - if (e.Data == null) - outputWaitHandle.Set(); - else - outputBuilder.AppendLine(e.Data); - }; + case ExitStatus.NotExited: + // Cancellation occurred before the process had exited. + if (killException is not null) + { + exception.Data["ProcessKillException"] = killException; + logger.LogWarning(killException, "Failed to kill process after cancellation for command: {Command}", command); + } + break; + + case ExitStatus.Exited: + // Process already exited + break; + + case ExitStatus.Indeterminate: + // Could not determine exit state due to an exception from Process.HasExited (no kill was attempted). + exception.Data["ProcessExitCheckException"] = exitCheck.CheckException; + logger.LogWarning(exitCheck.CheckException, "Could not determine process exit state during cancellation for command: {Command}", command); + break; - process.ErrorDataReceived += (sender, e) => + default: + throw new InvalidOperationException($"Unexpected exit status: {exitCheck.Status}"); + } + + exception.Data["ProcessName"] = process.SafeName(); + exception.Data["ProcessId"] = process.SafeId(); + exception.Data["ProcessExitStatus"] = exitCheck.Status.ToString(); + + // we deliberately preserve and rethrow (via 'throw;') the original OCE at call site. + } + + /// + /// Reads either stdout or stderr from a asynchronously. + /// Handlers are attached in the constructor, and reading begins when + /// is called after the process has started. + /// + /// + /// + /// Intended usage pattern: + /// + /// + /// Create and configure the with redirected streams. + /// Construct (handlers attach immediately). + /// Start the process. + /// Call to begin event-driven reading. + /// Await the returned task to obtain the full stream content. + /// + /// + /// + /// The reader does not handle cancellation directly - the underlying + /// owns the reader threads, which naturally terminate when the process exits or is killed. + /// This type: + /// + /// + /// Accumulates received lines into a buffer. + /// Completes when e.Data == null, the signal that the stream closed. + /// + /// Includes a safety-net completion in so callers cannot hang if + /// a close event is never raised (only relevant if used outside ExecuteAsync). + /// + /// + /// + private sealed class ProcessStreamReader : IDisposable + { + private readonly Process _process; + private readonly bool _isErrorStream; + private readonly ILogger _logger; + private readonly StringBuilder _buffer = new(); + private readonly TaskCompletionSource _tcs = new(TaskCreationOptions.RunContinuationsAsynchronously); + private readonly DataReceivedEventHandler _handler; + private bool _readingStarted; + private bool _disposed; + + public ProcessStreamReader(Process process, bool isErrorStream, ILogger logger) { - if (e.Data == null) - errorWaitHandle.Set(); + this._process = process ?? throw new ArgumentNullException(nameof(process)); + this._isErrorStream = isErrorStream; + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + + _handler = (_, e) => + { + if (e.Data is null) + { + // Stream closed – finalize accumulated output and complete. + _tcs.TrySetResult(_buffer.ToString()); + } + else + { + _buffer.AppendLine(e.Data); + } + }; + + if (isErrorStream) + { + process.ErrorDataReceived += _handler; + } else - errorBuilder.AppendLine(e.Data); - }; + { + process.OutputDataReceived += _handler; + } + } - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); + /// + /// Begins asynchronous reading of the associated stream. + /// Must be called only after has successfully completed. + /// + /// + /// This method does not accept a because the underlying + /// BeginOutputReadLine / BeginErrorReadLine APIs are event-driven and do not + /// support token-based cancellation. + /// + /// Cancellation of the overall external process operation is handled by ExecuteAsync, + /// which terminates the process on timeout or external cancellation. Once the process exits + /// (naturally or via kill), the stream read completes automatically. + /// + /// If ProcessStreamReader is ever used outside the normal ExecuteAsync workflow, + /// provides a safety net by completing the task even if the final + /// e.Data == null callback was never raised. + /// + /// A task that completes when the stream has fully drained. + public Task ReadToEndAsync() + { + ObjectDisposedException.ThrowIf(_disposed, this); - await Task.WhenAll( - Task.Run(() => process.WaitForExit(timeoutSeconds * 1000)), - Task.Run(() => + if (_readingStarted) { - outputWaitHandle.WaitOne(1000); - errorWaitHandle.WaitOne(1000); - }) - ); + throw new InvalidOperationException("StartReading has already been called for this reader."); + } - if (!process.HasExited) + _readingStarted = true; + + if (_isErrorStream) + { + _process.BeginErrorReadLine(); + } + else + { + _process.BeginOutputReadLine(); + } + + return _tcs.Task; + } + + /// + /// Disposes the stream reader by unsubscribing the data-received handler and ensuring + /// that the task returned by is completed as a safety net. + /// + /// + /// In the normal ExecuteAsync workflow, the read task will already have completed + /// before runs: + /// + /// Success path — the stream has fully drained. + /// Timeout or cancellation — ExecuteAsync has already raised an exception. + /// + /// + /// In these cases, the fallback completion inside is a harmless no-op. + /// Its purpose is to guarantee that callers of never block indefinitely if + /// ProcessStreamReader is used outside the intended ExecuteAsync flow and the + /// final e.Data == null callback is never delivered. + /// + public void Dispose() { - process.Kill(); - throw new TimeoutException($"Process execution timed out after {timeoutSeconds} seconds"); + if (_disposed) + { + return; + } + + _disposed = true; + + try + { + if (_isErrorStream) + { + _process.ErrorDataReceived -= _handler; + } + else + { + _process.OutputDataReceived -= _handler; + } + } + catch (Exception ex) + { + // Unsubscribing the handlers is a best-effort cleanup step; log and swallow any exceptions to avoid disposal throwing. + _logger.LogDebug( + ex, + "Unsubscribe from {StreamType} stream during disposal was skipped. Process: {ProcessName}, PID: {Pid}.", + StreamType, + _process.SafeName(), + _process.SafeId()); + } + + // Safety net: if the DataReceived handler _handler never observed the final e.Data == null + // and therefore never completed the read task, force completion here. + // + // We intentionally avoid calling ToString() on the shared StringBuilder _buffer to prevent + // any cross-thread races with AppendLine() inside the event handler. In all normal + // ExecuteAsync scenarios, the task will already be completed before Dispose() runs, + // making completion here a no-op. + // + // In timeout or cancellation scenarios, the caller receives an exception and does not + // consume stream output, so try completing with an empty string is sufficient and avoids + // touching potentially-mutating state. + if (_tcs.TrySetResult(string.Empty)) + { + _logger.LogDebug( + "ProcessStreamReader for {StreamType} (Process: {ProcessName}, PID: {Pid}) completed during disposal.", + StreamType, + _process.SafeName(), + _process.SafeId()); + } } - return new ProcessResult( - process.ExitCode, - outputBuilder.ToString().TrimEnd(), - errorBuilder.ToString().TrimEnd(), - $"{executablePath} {arguments}"); + private string StreamType => _isErrorStream ? "stderr" : "stdout"; } +} - public JsonElement ParseJsonOutput(ProcessResult result) +/// +/// Extensions for safe process inspection and kill operations. +/// +internal static class ProcessExtensions +{ + public static int SafeId(this Process process) { - if (result.ExitCode != 0) + try { - var error = new ParseError( - result.ExitCode, - result.Error, - result.Command - ); - return JsonSerializer.SerializeToElement(error, ServicesJsonContext.Default.ParseError); + return process.Id; + } + catch + { + return -1; } + } + public static string SafeName(this Process process) + { try { - using var jsonDocument = JsonDocument.Parse(result.Output); - return jsonDocument.RootElement.Clone(); + return process.ProcessName; } catch { - return JsonSerializer.SerializeToElement(new ParseOutput(result.Output), ServicesJsonContext.Default.ParseOutput); + return ""; } } - internal record ParseError( - int ExitCode, - string Error, - string Command - ); - - internal record ParseOutput([property: JsonPropertyName("output")] string Output); - - public void SetEnvironmentVariables(IDictionary variables) + /// + /// Gets the exit state of a process, defensively handling exceptions that may be thrown by . + /// + /// See official docs: + /// https://learn.microsoft.com/dotnet/api/system.diagnostics.process.hasexited + /// + /// + /// The process to check. + /// Logger for diagnostic messages. + /// + /// An indicating: + /// + /// with null exception if the process has exited. + /// with null exception if the process has not exited. + /// with null exception if is thrown. + /// + /// with the exception if or is thrown. + /// + /// + /// + public static ExitCheckResult CheckExitState(this Process process, ILogger logger) { - if (variables == null) + try + { + return process.HasExited + ? new ExitCheckResult(ExitStatus.Exited, CheckException: null) + : new ExitCheckResult(ExitStatus.NotExited, CheckException: null); + } + catch (InvalidOperationException checkException) + { + // Official docs: "No process is associated with this object." - treat as "already gone". + logger.LogDebug( + checkException, + "Process.HasExited reported no associated process. Treating as already exited. " + + "Process: {ProcessName}, PID: {Pid}", process.SafeName(), process.SafeId()); + return new ExitCheckResult(ExitStatus.Exited, CheckException: null); + } + catch (System.ComponentModel.Win32Exception checkException) + { + // Failure to read status (access denied, invalid handle, etc.). + return new ExitCheckResult(ExitStatus.Indeterminate, checkException); + } + catch (NotSupportedException checkException) { - return; + // Remote process or unsupported scenario – not a valid case for Azure MCP Server. + return new ExitCheckResult(ExitStatus.Indeterminate, checkException); } + } - foreach (var pair in variables) + /// + /// Best-effort attempt to terminate the process (and its tree), returning the exit state observed before + /// the kill attempt and any exception from the kill attempt. + /// + /// See official docs: + /// https://learn.microsoft.com/dotnet/api/system.diagnostics.process.Kill + /// + /// + /// The process to terminate. + /// Logger for diagnostic messages. + /// + /// + /// The returned reflects the state observed before the kill attempt. + /// If is reported, this method calls . + /// + /// Kill may throw: + /// + /// + /// – no process is associated with this object (for example, it has + /// already exited or the handle is invalid). In this case, the exception is logged as debug information and + /// is not treated as a kill failure, since there is nothing left to terminate. + /// + /// + /// or + /// – treated as genuine kill failures and returned to the caller for + /// diagnostic purposes. + /// + /// + /// + public static (ExitCheckResult ExitCheck, Exception? KillException) TryKill(this Process process, ILogger logger) + { + var exitCheck = process.CheckExitState(logger); + + if (exitCheck.Status == ExitStatus.NotExited) { - environmentVariables[pair.Key] = pair.Value; + try + { + process.Kill(entireProcessTree: true); + return (exitCheck, null); + } + catch (InvalidOperationException e) + { + // Official docs: "No process is associated with this object." - treat as "already gone". + logger.LogDebug( + e, + "Process.Kill reported no associated process. Treating as already exited. " + + "Process: {ProcessName}, PID: {Pid}", process.SafeName(), process.SafeId()); + + // Considered success from a termination perspective: nothing left to kill. + return (exitCheck, null); + } + catch (System.ComponentModel.Win32Exception e) + { + // Failure to terminate (access denied, invalid handle, etc.). + return (exitCheck, e); + } + catch (NotSupportedException e) + { + // Remote process or unsupported scenario – not a valid case for Azure MCP Server. + return (exitCheck, e); + } } + + // Process has already exited or exit state was indeterminate; nothing to kill. + return (exitCheck, null); } + + /// + /// Represents the exit status of a process. + /// + public enum ExitStatus + { + /// + /// The process has exited. + /// + Exited, + + /// + /// The process has not exited. + /// + NotExited, + + /// + /// The process exit status could not be determined because + /// threw an exception during the check. + /// + Indeterminate + } + + /// + /// Represents the observed exit state of a process as determined by + /// . + /// + /// This captures: + /// + /// + /// + /// The + /// + /// + /// + /// + /// An associated exception only when the status is + /// + /// + /// + /// + public record ExitCheckResult(ExitStatus Status, Exception? CheckException); } diff --git a/core/Azure.Mcp.Core/src/Services/ProcessExecution/IExternalProcessService.cs b/core/Azure.Mcp.Core/src/Services/ProcessExecution/IExternalProcessService.cs index 651915c6cb..e4c17a1317 100644 --- a/core/Azure.Mcp.Core/src/Services/ProcessExecution/IExternalProcessService.cs +++ b/core/Azure.Mcp.Core/src/Services/ProcessExecution/IExternalProcessService.cs @@ -12,18 +12,30 @@ public record ProcessResult( public interface IExternalProcessService { /// - /// Executes an external process and returns the result + /// Executes an external process and captures its standard output and error streams. /// - /// Name of the executable to find in PATH or common install locations - /// Arguments to pass to the executable - /// Timeout in seconds - /// Optional additional paths to search for the executable - /// Process execution result containing exit code, output and error streams + /// Full path to the executable to run. + /// Command-line arguments to pass to the executable. + /// + /// Optional environment variables to set for the process. If null or not provided, no additional + /// environment variables will be set beyond those inherited from the parent process. + /// + /// + /// Total timeout in seconds for the entire operation, including process execution and output capture. + /// Defaults to 300 seconds (5 minutes). Must be greater than zero. + /// + /// + /// Cancellation token to abort the operation. The process will be terminated if cancellation is requested. + /// + /// + /// A containing the exit code, standard output, standard error, and command string. + /// Task ExecuteAsync( - string executableName, + string executablePath, string arguments, - int timeoutSeconds = 300, - IEnumerable? customPaths = null); + IDictionary? environmentVariables = default, + int operationTimeoutSeconds = 300, + CancellationToken cancellationToken = default); /// /// Tries to parse the process output as JSON and return it as JsonElement @@ -31,10 +43,4 @@ Task ExecuteAsync( /// Process execution result /// Parsed JSON element or formatted error object if parsing fails JsonElement ParseJsonOutput(ProcessResult result); - - /// - /// Sets environment variables for the process execution - /// /// - /// Dictionary of environment variables to set - void SetEnvironmentVariables(IDictionary variables); } diff --git a/core/Azure.Mcp.Core/src/Services/Telemetry/ITelemetryService.cs b/core/Azure.Mcp.Core/src/Services/Telemetry/ITelemetryService.cs index 54958f93e8..f9837fa778 100644 --- a/core/Azure.Mcp.Core/src/Services/Telemetry/ITelemetryService.cs +++ b/core/Azure.Mcp.Core/src/Services/Telemetry/ITelemetryService.cs @@ -8,7 +8,26 @@ namespace Azure.Mcp.Core.Services.Telemetry; public interface ITelemetryService : IDisposable { - ValueTask StartActivity(string activityName); + /// + /// Creates and starts a new telemetry activity. + /// + /// Name of the activity. + /// An Activity object or null if there are no active listeners or telemetry is disabled. + /// If the service is not in an operational state or was not invoked. + Activity? StartActivity(string activityName); - ValueTask StartActivity(string activityName, Implementation? clientInfo); + /// + /// Creates and starts a new telemetry activity. + /// + /// Name of the activity. + /// The MCP client information to add to the activity. + /// An Activity object or null if there are no active listeners or telemetry is disabled. + /// If the service is not in an operational state or was not invoked. + Activity? StartActivity(string activityName, Implementation? clientInfo); + + /// + /// Performs any initialization operations before telemetry service is ready. + /// + /// A task that completes when initialization is complete. + Task InitializeAsync(); } diff --git a/core/Azure.Mcp.Core/src/Services/Telemetry/TelemetryConstants.cs b/core/Azure.Mcp.Core/src/Services/Telemetry/TelemetryConstants.cs index c3ce463969..8d8f5b3f78 100644 --- a/core/Azure.Mcp.Core/src/Services/Telemetry/TelemetryConstants.cs +++ b/core/Azure.Mcp.Core/src/Services/Telemetry/TelemetryConstants.cs @@ -19,8 +19,21 @@ internal class TagName public const string MacAddressHash = "MacAddressHash"; public const string ResourceHash = "AzResourceHash"; public const string SubscriptionGuid = "AzSubscriptionGuid"; + public const string ToolId = "ToolId"; public const string ToolName = "ToolName"; public const string ToolArea = "ToolArea"; + public const string ServerMode = "ServerMode"; + public const string IsServerCommandInvoked = "IsServerCommandInvoked"; + public const string Transport = "Transport"; + public const string IsReadOnly = "IsReadOnly"; + public const string Namespace = "Namespace"; + public const string ToolCount = "ToolCount"; + public const string InsecureDisableElicitation = "InsecureDisableElicitation"; + public const string IsDebug = "IsDebug"; + public const string DangerouslyDisableHttpIncomingAuth = "DangerouslyDisableHttpIncomingAuth"; + public const string Tool = "Tool"; + public const string VSCodeConversationId = "VSCodeConversationId"; + public const string VSCodeRequestId = "VSCodeRequestId"; } internal class ActivityName @@ -28,5 +41,6 @@ internal class ActivityName public const string CommandExecuted = "CommandExecuted"; public const string ListToolsHandler = "ListToolsHandler"; public const string ToolExecuted = "ToolExecuted"; + public const string ServerStarted = "ServerStarted"; } } diff --git a/core/Azure.Mcp.Core/src/Services/Telemetry/TelemetryService.cs b/core/Azure.Mcp.Core/src/Services/Telemetry/TelemetryService.cs index 68bf9a4897..45e84690f2 100644 --- a/core/Azure.Mcp.Core/src/Services/Telemetry/TelemetryService.cs +++ b/core/Azure.Mcp.Core/src/Services/Telemetry/TelemetryService.cs @@ -2,7 +2,10 @@ // Licensed under the MIT License. using System.Diagnostics; +using System.Text.Json.Nodes; +using Azure.Mcp.Core.Areas.Server.Options; using Azure.Mcp.Core.Configuration; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ModelContextProtocol.Protocol; using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants; @@ -14,39 +17,77 @@ namespace Azure.Mcp.Core.Services.Telemetry; /// internal class TelemetryService : ITelemetryService { + private readonly IMachineInformationProvider _informationProvider; private readonly bool _isEnabled; + private readonly ILogger _logger; private readonly List> _tagsList; - private readonly IMachineInformationProvider _informationProvider; - private readonly TaskCompletionSource _isInitialized = new TaskCompletionSource(); + private readonly SemaphoreSlim _initalizeLock = new(1); + + /// + /// Task created on the first invocation of . + /// This is saved so that repeated invocations will see the same exception + /// as the first invocation. + /// + private Task? _initalizationTask = null; + + private bool _initializationSuccessful; + private bool _isInitialized; internal ActivitySource Parent { get; } - public TelemetryService(IMachineInformationProvider informationProvider, IOptions options) + public TelemetryService(IMachineInformationProvider informationProvider, + IOptions options, + IOptions? serverOptions, + ILogger logger) { _isEnabled = options.Value.IsTelemetryEnabled; - _tagsList = new List>() - { + _tagsList = + [ new(TagName.AzureMcpVersion, options.Value.Version), - }; + ]; + + if (serverOptions?.Value != null) + { + _tagsList.Add(new(TagName.ServerMode, serverOptions.Value.Mode)); + } Parent = new ActivitySource(options.Value.Name, options.Value.Version, _tagsList); _informationProvider = informationProvider; + _logger = logger; + } + + /// + /// TESTING PURPOSES ONLY: Gets the default tags used for telemetry. + /// + internal IReadOnlyList> GetDefaultTags() + { + if (!_isEnabled) + { + return []; + } - Task.Factory.StartNew(InitializeTagList); + CheckInitialization(); + return [.. _tagsList]; } - public ValueTask StartActivity(string activityId) => StartActivity(activityId, null); + /// + /// + /// + public Activity? StartActivity(string activityName) => StartActivity(activityName, null); - public async ValueTask StartActivity(string activityId, Implementation? clientInfo) + /// + /// + /// + public Activity? StartActivity(string activityName, Implementation? clientInfo) { if (!_isEnabled) { return null; } - await _isInitialized.Task; + CheckInitialization(); - var activity = Parent.StartActivity(activityId); + var activity = Parent.StartActivity(activityName); if (activity == null) { @@ -70,21 +111,79 @@ public void Dispose() { } - private async Task InitializeTagList() + /// + /// + /// + public async Task InitializeAsync() { - try + if (!_isEnabled) { - var macAddressHash = await _informationProvider.GetMacAddressHash(); - var deviceId = await _informationProvider.GetOrCreateDeviceId(); + return; + } - _tagsList.Add(new(TagName.MacAddressHash, macAddressHash)); - _tagsList.Add(new(TagName.DevDeviceId, deviceId)); + // Quick check if initialization already happened. Avoids + // trying to get the lock. + if (_initalizationTask == null) + { + // Get async lock for starting initialization + await _initalizeLock.WaitAsync(); + + try + { + // Check after acquiring lock to ensure we honor work + // started while we were waiting. + if (_initalizationTask == null) + { + _initalizationTask = InnerInitializeAsync(); + } + } + finally + { + _initalizeLock.Release(); + } + } - _isInitialized.SetResult(); + // Await the response of the initialization work regardless of if + // we or another invocation created the Task representing it. All + // awaiting on this will give the same result to ensure idempotency. + await _initalizationTask; + + async Task InnerInitializeAsync() + { + try + { + var macAddressHash = await _informationProvider.GetMacAddressHash(); + var deviceId = await _informationProvider.GetOrCreateDeviceId(); + + _tagsList.Add(new(TagName.MacAddressHash, macAddressHash)); + _tagsList.Add(new(TagName.DevDeviceId, deviceId)); + + _initializationSuccessful = true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error occurred initializing telemetry service."); + throw; + } + finally + { + _isInitialized = true; + } } - catch (Exception ex) + } + + private void CheckInitialization() + { + if (!_isInitialized) + { + throw new InvalidOperationException( + $"Telemetry service has not been initialized. Use {nameof(InitializeAsync)}() before any other operations."); + } + + if (!_initializationSuccessful) { - _isInitialized.SetException(ex); + throw new InvalidOperationException("Telemetry service was not successfully initialized. Check logs for initialization errors."); } + } } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Areas/Server/ServerCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Areas/Server/ServerCommandTests.cs index b4a78886d1..5b276f2325 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Areas/Server/ServerCommandTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Areas/Server/ServerCommandTests.cs @@ -16,7 +16,7 @@ public class ServerCommandTests(ITestOutputHelper output) { protected ITestOutputHelper Output { get; } = output; - private async Task CreateClientAsync(params string[] arguments) + private async Task CreateClientAsync(params string[] arguments) { var settingsFixture = new LiveTestSettingsFixture(); await settingsFixture.InitializeAsync(); @@ -46,7 +46,7 @@ private async Task CreateClientAsync(params string[] arguments) } var clientTransport = new StdioClientTransport(transportOptions); - return await McpClientFactory.CreateAsync(clientTransport); + return await McpClient.CreateAsync(clientTransport); } #region Default Mode Tests @@ -69,7 +69,7 @@ public async Task DefaultMode_LoadsNamespaceTools() // Default mode is now namespace mode, so should have namespace-level tools (not 60+ individual tools) Assert.True(toolNames.Count > 20, $"Expected more than 20 namespace tools, got {toolNames.Count}"); - // Should include the documentation tool + // Should include the documentation tool (displayed by its title) Assert.Contains("documentation", toolNames, StringComparer.OrdinalIgnoreCase); // Log for debugging @@ -80,6 +80,78 @@ public async Task DefaultMode_LoadsNamespaceTools() } } + [Fact] + public async Task DefaultMode_IncludesUtilityCommands() + { + // Arrange + await using var client = await CreateClientAsync("server", "start"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(listResult); + + var toolNames = listResult.Select(t => t.Name).ToList(); + + // Should include subscription and group utility commands + Assert.Contains(toolNames, name => name.Contains("subscription", StringComparison.OrdinalIgnoreCase) && name.Contains("list", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(toolNames, name => name.Contains("group", StringComparison.OrdinalIgnoreCase) && name.Contains("list", StringComparison.OrdinalIgnoreCase)); + + // Log for debugging + Output.WriteLine($"Default mode loaded utility commands:"); + foreach (var name in toolNames.Where(n => n.Contains("subscription", StringComparison.OrdinalIgnoreCase) || n.Contains("group", StringComparison.OrdinalIgnoreCase))) + { + Output.WriteLine($" - {name}"); + } + } + + [Fact] + public async Task DefaultMode_CanCallSubscriptionList() + { + // Arrange + await using var client = await CreateClientAsync("server", "start"); + + // Act + var result = await client.CallToolAsync("subscription_list", new Dictionary { }, + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + + // The result should contain subscription data (even if empty list) + var firstContent = result.Content.FirstOrDefault(); + Assert.NotNull(firstContent); + + // Log for debugging + Output.WriteLine($"Subscription list result: {firstContent}"); + } + + [Fact] + public async Task DefaultMode_CanCallGroupList() + { + // Arrange + await using var client = await CreateClientAsync("server", "start"); + + // Act + var result = await client.CallToolAsync("group_list", new Dictionary { }, + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + + // The result should contain resource group data (even if empty list) + var firstContent = result.Content.FirstOrDefault(); + Assert.NotNull(firstContent); + + // Log for debugging + Output.WriteLine($"Group list result: {firstContent}"); + } + #endregion #region All Mode Tests @@ -203,7 +275,7 @@ public async Task NamespaceProxyMode_LoadsNamespaceTools() // In namespace mode without specific namespaces, should default to extension tools Assert.True(toolNames.Count > 20, "Should have more than 20 tools in namespace mode"); - // Should include the documentation tool + // Should include the documentation tool (displayed by its title) Assert.Contains("documentation", toolNames, StringComparer.OrdinalIgnoreCase); Output.WriteLine($"Namespace proxy mode loaded {toolNames.Count} tools"); @@ -237,15 +309,17 @@ public async Task NamespaceProxyMode_WithSpecificNamespaces_LoadsNamespaceSpecif // Should not include documentation tool when explicit namespaces are specified Assert.DoesNotContain("documentation", toolNames, StringComparer.OrdinalIgnoreCase); - // Should contain exactly 2 tools for the specified namespaces - Assert.Equal(2, toolNames.Count); + // Should contain exactly 4 tools: 2 specified namespaces + 2 utility tools (group_list, subscription_list) + Assert.Equal(4, toolNames.Count); - // Verify tools are exactly from storage and keyvault namespaces + // Verify tools are from storage, keyvault namespaces, or utility tools Assert.All(toolNames, toolName => { var isStorageOrKeyVault = toolName.Contains("storage", StringComparison.OrdinalIgnoreCase) || toolName.Contains("keyvault", StringComparison.OrdinalIgnoreCase); - Assert.True(isStorageOrKeyVault, $"Tool '{toolName}' should be related to storage or keyvault namespaces"); + var isUtilityTool = toolName.Contains("group", StringComparison.OrdinalIgnoreCase) || + toolName.Contains("subscription", StringComparison.OrdinalIgnoreCase); + Assert.True(isStorageOrKeyVault || isUtilityTool, $"Tool '{toolName}' should be related to storage, keyvault namespaces, or be a utility tool"); }); Output.WriteLine($"Namespace proxy mode with [storage, keyvault] loaded {toolNames.Count} tools"); @@ -265,12 +339,11 @@ public async Task NamespaceProxyMode_WithDocumentationNamespace_LoadsOnlyDocumen var toolNames = listResult.Select(t => t.Name).ToList(); - // Should contain only the documentation tool - Assert.Single(listResult); + // Should contain the documentation tool (displayed by its title) plus utility tools + Assert.Equal(3, listResult.Count()); Assert.Contains("documentation", toolNames, StringComparer.OrdinalIgnoreCase); - - // Verify it's exactly the documentation tool - Assert.Equal("documentation", toolNames.First(), StringComparer.OrdinalIgnoreCase); + Assert.Contains(toolNames, name => name.Contains("group", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(toolNames, name => name.Contains("subscription", StringComparison.OrdinalIgnoreCase)); Output.WriteLine($"Namespace proxy mode with [documentation] loaded {toolNames.Count} tools"); Output.WriteLine($"Tool: {toolNames.First()}"); @@ -330,7 +403,7 @@ public async Task DefaultMode_WithNamespaceFilter_LoadsFilteredTools() // Assert Assert.NotEmpty(listResult); - Assert.Equal(2, listResult.Count()); + Assert.Equal(4, listResult.Count()); // 2 specified namespaces + 2 utility tools var toolNames = listResult.Select(t => t.Name).ToList(); @@ -450,11 +523,14 @@ public async Task InvalidNamespace_LoadsGracefully() var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); // Assert - // Should not crash, but may have fewer or no tools + // Should not crash, but may have fewer tools Assert.NotNull(listResult); - // Invalid namespaces should result in 0 tools - Assert.Empty(listResult); + // Invalid namespaces should result in only utility tools (group_list, subscription_list) + Assert.Equal(2, listResult.Count()); + var toolNames = listResult.Select(t => t.Name).ToList(); + Assert.Contains(toolNames, name => name.Contains("group", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(toolNames, name => name.Contains("subscription", StringComparison.OrdinalIgnoreCase)); Output.WriteLine($"Invalid namespaces loaded {listResult.Count()} tools"); } @@ -500,4 +576,216 @@ public async Task VerifyUniqueToolNames_InDefaultMode() } #endregion + + #region Consolidated Proxy Mode Tests + + [Fact] + public async Task ConsolidatedProxyMode_LoadsConsolidatedTools() + { + // Arrange + await using var client = await CreateClientAsync("server", "start", "--mode", "consolidated"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(listResult); + + var toolNames = listResult.Select(t => t.Name).ToList(); + + // In consolidated mode, should have consolidated tools grouping related operations + Assert.True(toolNames.Count > 20, $"Expected more than 20 consolidated tools, got {toolNames.Count}"); + + // Should include some known consolidated tools + Assert.Contains(toolNames, name => name.Contains("azure_subscriptions_and_resource_groups", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(toolNames, name => name.Contains("azure_databases_details", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(toolNames, name => name.Contains("azure_storage_details", StringComparison.OrdinalIgnoreCase)); + + Output.WriteLine($"Consolidated proxy mode loaded {toolNames.Count} tools"); + foreach (var name in toolNames) + { + Output.WriteLine($" - {name}"); + } + } + + [Fact] + public async Task ConsolidatedProxyMode_ToolLearnMode_ReturnsConsolidatedCommands() + { + // Arrange + await using var client = await CreateClientAsync("server", "start", "--mode", "consolidated"); + + // Act - Call a consolidated tool in learn mode + var learnParameters = new Dictionary + { + ["learn"] = true + }; + + var result = await client.CallToolAsync("get_azure_databases_details", learnParameters, cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + + // Get the text content + var textContent = result.Content.OfType().FirstOrDefault(); + Assert.NotNull(textContent); + Assert.NotEmpty(textContent.Text); + + var responseText = textContent.Text; + + // Verify the response contains information about database commands + Assert.Contains("available command", responseText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("database", responseText, StringComparison.OrdinalIgnoreCase); + + // Verify it contains multiple database-related commands + Assert.Contains("mysql", responseText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("postgres", responseText, StringComparison.OrdinalIgnoreCase); + Assert.Contains("sql", responseText, StringComparison.OrdinalIgnoreCase); + + Output.WriteLine("Consolidated database tool learn mode response:"); + Output.WriteLine(responseText); + Output.WriteLine($"✓ Learn mode returned {responseText.Length} characters of consolidated command information"); + } + + [Fact] + public async Task ConsolidatedProxyMode_WithNamespaceFilter_LoadsFilteredConsolidatedTools() + { + // Arrange + await using var client = await CreateClientAsync("server", "start", "--mode", "consolidated", "--namespace", "storage"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(listResult); + + var toolNames = listResult.Select(t => t.Name).ToList(); + + // Should only include consolidated tools related to specified namespaces + var hasRelevantTools = toolNames.Any(name => + name.Contains("storage", StringComparison.OrdinalIgnoreCase)); + + Assert.True(hasRelevantTools, "Should have consolidated tools related to storage namespaces"); + + // In consolidated mode with namespace filter, should have fewer tools than without filter + Assert.True(toolNames.Count < 10, $"Expected fewer than 10 tools with namespace filter, got {toolNames.Count}"); + + Output.WriteLine($"Consolidated proxy mode with [storage] namespaces loaded {toolNames.Count} tools"); + foreach (var name in toolNames) + { + Output.WriteLine($" - {name}"); + } + } + + [Fact] + public async Task ConsolidatedProxyMode_WithReadOnlyFlag_LoadsOnlyReadOnlyConsolidatedTools() + { + // Arrange + await using var client = await CreateClientAsync("server", "start", "--mode", "consolidated", "--read-only"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(listResult); + + var toolCount = listResult.Count(); + Assert.True(toolCount > 0, "Should have at least some read-only consolidated tools"); + + // Verify all tools have read-only annotations + var toolsWithReadOnlyHint = 0; + var toolsWithAnnotations = 0; + + foreach (var tool in listResult) + { + var hasAnnotations = tool.ProtocolTool?.Annotations != null; + var readOnlyHint = tool.ProtocolTool?.Annotations?.ReadOnlyHint; + + if (hasAnnotations) + { + toolsWithAnnotations++; + } + + if (readOnlyHint.HasValue && readOnlyHint.Value) + { + toolsWithReadOnlyHint++; + } + + // Verify tool names don't contain destructive operations + Assert.DoesNotContain("create_", tool.Name, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("edit_", tool.Name, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("delete_", tool.Name, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain("update_", tool.Name, StringComparison.OrdinalIgnoreCase); + } + } + + [Fact] + public async Task ConsolidatedProxyMode_CanCallConsolidatedTool() + { + // Arrange + await using var client = await CreateClientAsync("server", "start", "--mode", "consolidated"); + + // Act - Call the consolidated subscriptions and resource groups tool + var result = await client.CallToolAsync("get_azure_subscriptions_and_resource_groups", + new Dictionary { }, + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Content); + Assert.NotEmpty(result.Content); + + // The result should contain subscription and resource group data + var firstContent = result.Content.FirstOrDefault(); + Assert.NotNull(firstContent); + + // Log for debugging + Output.WriteLine($"Consolidated tool result: {firstContent}"); + } + + #endregion + + #region Tool Mode Tests + + [Fact] + public async Task ToolMode_AutomaticallyChangesToAllMode() + { + // Arrange - Test that --tool switch automatically changes mode to "all" + await using var client = await CreateClientAsync("server", "start", "--tool", "group_list", "--tool", "subscription_list"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(listResult); + + var toolNames = listResult.Select(t => t.Name).ToList(); + + // Should only include the specified tools + Assert.Equal(2, toolNames.Count); + Assert.Contains("group_list", toolNames); + Assert.Contains("subscription_list", toolNames); + } + + [Fact] + public async Task ToolMode_OverridesExplicitNamespaceMode() + { + // Arrange - Test that --tool switch overrides --mode namespace + await using var client = await CreateClientAsync("server", "start", "--mode", "namespace", "--tool", "group_list"); + + // Act + var listResult = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotEmpty(listResult); + + var toolNames = listResult.Select(t => t.Name).ToList(); + + // Should only include the specified tool, mode should be automatically changed to "all" + Assert.Single(toolNames); + Assert.Contains("group_list", toolNames); + } + + #endregion } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Azure.Mcp.Core.LiveTests.csproj b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Azure.Mcp.Core.LiveTests.csproj index b6a05696f4..45eb7dff93 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Azure.Mcp.Core.LiveTests.csproj +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Azure.Mcp.Core.LiveTests.csproj @@ -1,4 +1,4 @@ - + true Exe diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/ClientToolTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/ClientToolTests.cs index df3daf5236..56c4a5853c 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/ClientToolTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/ClientToolTests.cs @@ -6,7 +6,6 @@ using Azure.Mcp.Tests.Client; using Azure.Mcp.Tests.Client.Helpers; using ModelContextProtocol; -using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using Xunit; @@ -25,7 +24,7 @@ public async Task Should_List_Tools() [Fact] public async Task Client_Should_Invoke_Tool_Successfully() { - var result = await Client.CallToolAsync("azmcp_subscription_list", new Dictionary { }, + var result = await Client.CallToolAsync("subscription_list", new Dictionary { }, cancellationToken: TestContext.Current.CancellationToken); string? content = McpTestUtilities.GetFirstText(result.Content); @@ -65,7 +64,7 @@ public async Task Client_Should_Ping_Server_Successfully() [Fact] public async Task Should_Error_When_Resources_List_Not_Supported() { - var ex = await Assert.ThrowsAsync(async () => await Client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken)); + var ex = await Assert.ThrowsAsync(async () => await Client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken)); Assert.Contains("Request failed", ex.Message); Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); } @@ -73,7 +72,7 @@ public async Task Should_Error_When_Resources_List_Not_Supported() [Fact] public async Task Should_Error_When_Resources_Read_Not_Supported() { - var ex = await Assert.ThrowsAsync(async () => await Client.ReadResourceAsync("test://resource", cancellationToken: TestContext.Current.CancellationToken)); + var ex = await Assert.ThrowsAsync(async () => await Client.ReadResourceAsync("test://resource", cancellationToken: TestContext.Current.CancellationToken)); Assert.Contains("Request failed", ex.Message); Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); } @@ -81,7 +80,7 @@ public async Task Should_Error_When_Resources_Read_Not_Supported() [Fact] public async Task Should_Error_When_Resources_Templates_List_Not_Supported() { - var ex = await Assert.ThrowsAsync(async () => await Client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken)); + var ex = await Assert.ThrowsAsync(async () => await Client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken)); Assert.Contains("Request failed", ex.Message); Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); } @@ -89,7 +88,7 @@ public async Task Should_Error_When_Resources_Templates_List_Not_Supported() [Fact] public async Task Should_Error_When_Resources_Subscribe_Not_Supported() { - var ex = await Assert.ThrowsAsync(async () => await Client.SubscribeToResourceAsync("test://resource", cancellationToken: TestContext.Current.CancellationToken)); + var ex = await Assert.ThrowsAsync(async () => await Client.SubscribeToResourceAsync("test://resource", cancellationToken: TestContext.Current.CancellationToken)); Assert.Contains("Request failed", ex.Message); Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); } @@ -97,7 +96,7 @@ public async Task Should_Error_When_Resources_Subscribe_Not_Supported() [Fact] public async Task Should_Error_When_Resources_Unsubscribe_Not_Supported() { - var ex = await Assert.ThrowsAsync(async () => await Client.UnsubscribeFromResourceAsync("test://resource", cancellationToken: TestContext.Current.CancellationToken)); + var ex = await Assert.ThrowsAsync(async () => await Client.UnsubscribeFromResourceAsync("test://resource", cancellationToken: TestContext.Current.CancellationToken)); Assert.Contains("Request failed", ex.Message); Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); } @@ -111,7 +110,7 @@ public async Task Should_Not_Hang_On_Logging_SetLevel_Not_Supported() [Fact] public async Task Should_Error_When_Prompts_List_Not_Supported() { - var ex = await Assert.ThrowsAsync(async () => await Client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken)); + var ex = await Assert.ThrowsAsync(async () => await Client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken)); Assert.Contains("Request failed", ex.Message); Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); } @@ -119,7 +118,7 @@ public async Task Should_Error_When_Prompts_List_Not_Supported() [Fact] public async Task Should_Error_When_Prompts_Get_Not_Supported() { - var ex = await Assert.ThrowsAsync(async () => await Client.GetPromptAsync("unsupported_prompt", cancellationToken: TestContext.Current.CancellationToken)); + var ex = await Assert.ThrowsAsync(async () => await Client.GetPromptAsync("unsupported_prompt", cancellationToken: TestContext.Current.CancellationToken)); Assert.Contains("Request failed", ex.Message); Assert.Equal(McpErrorCode.MethodNotFound, ex.ErrorCode); } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/CommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/CommandTests.cs index a59deb88aa..09152b5606 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/CommandTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/CommandTests.cs @@ -14,7 +14,7 @@ public class CommandTests(ITestOutputHelper output) : CommandTestsBase(output) public async Task Should_list_groups_by_subscription() { var result = await CallToolAsync( - "azmcp_group_list", + "group_list", new() { { "subscription", Settings.SubscriptionId } @@ -29,7 +29,7 @@ public async Task Should_list_groups_by_subscription() public async Task Should_list_subscriptions() { var result = await CallToolAsync( - "azmcp_subscription_list", + "subscription_list", new Dictionary()); var subscriptionsArray = result.AssertProperty("subscriptions"); diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/RecordingFramework/RecordedCommandTestHarness.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/RecordingFramework/RecordedCommandTestHarness.cs new file mode 100644 index 0000000000..c8520efd34 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/RecordingFramework/RecordedCommandTestHarness.cs @@ -0,0 +1,65 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using System.IO; +using System.Text; +using Azure.Mcp.Tests.Client; +using Azure.Mcp.Tests.Client.Attributes; +using Azure.Mcp.Tests.Client.Helpers; +using Azure.Mcp.Tests.Generated.Models; +using Azure.Mcp.Tests.Helpers; +using Xunit; + +namespace Azure.Mcp.Core.LiveTests.RecordingFramework; + +/// +/// Harness for testing RecordedCommandTestsBase functionality. Intended for proper abstraction of livetest settings etc to allow both record and playback modes in the same test for full roundtrip testing. +/// +/// +/// +internal sealed class RecordedCommandTestHarness(ITestOutputHelper output, TestProxyFixture fixture) : RecordedCommandTestsBase(output, fixture) +{ + public TestMode DesiredMode { get; set; } = TestMode.Record; + + public IReadOnlyDictionary Variables => TestVariables; + + public string GetRecordingAbsolutePath(string displayName) + { + var sanitized = RecordingPathResolver.Sanitize(displayName); + var relativeDirectory = PathResolver.GetSessionDirectory(GetType(), variantSuffix: null) + .Replace('/', Path.DirectorySeparatorChar); + var fileName = RecordingPathResolver.BuildFileName(sanitized, IsAsync, VersionQualifier); + var absoluteDirectory = Path.Combine(PathResolver.RepositoryRoot, relativeDirectory); + Directory.CreateDirectory(absoluteDirectory); + return Path.Combine(absoluteDirectory, fileName); + } + + protected override ValueTask LoadSettingsAsync() + { + Settings = new LiveTestSettings + { + SubscriptionId = "00000000-0000-0000-0000-000000000000", + TenantId = "00000000-0000-0000-0000-000000000000", + ResourceBaseName = "Sanitized", + SubscriptionName = "Sanitized", + TenantName = "Sanitized", + TestMode = TestMode.Playback + }; + + Settings.TestMode = DesiredMode; + TestMode = DesiredMode; + + return ValueTask.CompletedTask; + } + + public void ResetVariables() + { + TestVariables.Clear(); + } + + public string GetRecordingId() + { + return RecordingId; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/RecordingFramework/RecordedCommandTestsBaseTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/RecordingFramework/RecordedCommandTestsBaseTests.cs new file mode 100644 index 0000000000..8c99fd9027 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/RecordingFramework/RecordedCommandTestsBaseTests.cs @@ -0,0 +1,182 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Reflection; +using System.Text.Json; +using Azure.Mcp.Tests.Client; +using Azure.Mcp.Tests.Client.Attributes; +using Azure.Mcp.Tests.Client.Helpers; +using Azure.Mcp.Tests.Generated.Models; +using Azure.Mcp.Tests.Helpers; +using Microsoft.Extensions.FileSystemGlobbing; +using NSubstitute; +using Xunit; +using Xunit.v3; + +namespace Azure.Mcp.Core.LiveTests.RecordingFramework; + +public sealed class RecordedCommandTestsBaseTest : IAsyncLifetime +{ + private string RecordingFileLocation = string.Empty; + private string TestDisplayName = string.Empty; + private TestProxyFixture Fixture = new TestProxyFixture(); + private ITestOutputHelper CollectedOutput = Substitute.For(); + private RecordedCommandTestHarness? DefaultHarness; + + [Fact] + public async Task ProxyRecordProducesRecording() + { + await DefaultHarness!.InitializeAsync(); + + Assert.NotNull(Fixture.Proxy); + Assert.False(string.IsNullOrWhiteSpace(Fixture.Proxy!.BaseUri)); + + DefaultHarness!.RegisterVariable("sampleKey", "sampleValue"); + await DefaultHarness!.DisposeAsync(); + + Assert.True(File.Exists(RecordingFileLocation)); + + using var document = JsonDocument.Parse(await File.ReadAllTextAsync(RecordingFileLocation, TestContext.Current.CancellationToken)); + Assert.True(document.RootElement.TryGetProperty("Variables", out var variablesElement)); + Assert.Equal("sampleValue", variablesElement.GetProperty("sampleKey").GetString()); + } + + [CustomMatcher(IgnoreQueryOrdering = true, CompareBodies = true)] + [Fact] + public async Task PerTestMatcherAttributeAppliesWhenPresent() + { + var activeMatcher = GetActiveMatcher(); + Assert.NotNull(activeMatcher); + Assert.True(activeMatcher!.CompareBodies); + Assert.True(activeMatcher.IgnoreQueryOrdering); + + DefaultHarness = new RecordedCommandTestHarness(CollectedOutput, Fixture) + { + DesiredMode = TestMode.Record, + EnableDefaultSanitizerAdditions = false, + }; + var recordingId = string.Empty; + + await DefaultHarness.InitializeAsync(); + DefaultHarness.RegisterVariable("attrKey", "attrValue"); + await DefaultHarness.DisposeAsync(); + + var playbackHarness = new RecordedCommandTestHarness(CollectedOutput, Fixture) + { + DesiredMode = TestMode.Playback, + EnableDefaultSanitizerAdditions = false, + }; + + await playbackHarness.InitializeAsync(); + recordingId = playbackHarness.GetRecordingId(); + await playbackHarness.DisposeAsync(); + + CollectedOutput.Received().WriteLine(Arg.Is(s => s.Contains($"Applying custom matcher to recordingId \"{recordingId}\""))); + } + + [Fact] + public void CustomMatcherAttributeClearsAfterExecution() + { + var attribute = new CustomMatcherAttribute(compareBody: true, ignoreQueryordering: true); + var xunitTest = Substitute.For(); + var methodInfo = typeof(RecordedCommandTestsBaseTest).GetMethod(nameof(CustomMatcherAttributeClearsAfterExecution)) + ?? throw new InvalidOperationException("Unable to locate test method for CustomMatcherAttribute verification."); + + attribute.Before(methodInfo, xunitTest); + try + { + var active = GetActiveMatcher(); + Assert.Same(attribute, active); + Assert.True(active!.CompareBodies); + Assert.True(active.IgnoreQueryOrdering); + } + finally + { + attribute.After(methodInfo, xunitTest); + } + + Assert.Null(GetActiveMatcher()); + } + + private static CustomMatcherAttribute? GetActiveMatcher() + { + var method = typeof(CustomMatcherAttribute).GetMethod("GetActive", BindingFlags.NonPublic | BindingFlags.Static); + return (CustomMatcherAttribute?)method?.Invoke(null, null); + } + + [Fact] + public async Task GlobalMatcherAndSanitizerAppliesWhenPresent() + { + DefaultHarness = new RecordedCommandTestHarness(CollectedOutput, Fixture) + { + DesiredMode = TestMode.Record, + TestMatcher = new CustomDefaultMatcher + { + CompareBodies = true, + IgnoreQueryOrdering = true, + } + }; + + DefaultHarness.GeneralRegexSanitizers.Add(new GeneralRegexSanitizer(new GeneralRegexSanitizerBody + { + Regex = "sample", + Value = "sanitized", + })); + DefaultHarness.DisabledDefaultSanitizers.Add("UriSubscriptionIdSanitizer"); + + await DefaultHarness.InitializeAsync(); + await DefaultHarness.DisposeAsync(); + + CollectedOutput.Received().WriteLine(Arg.Is(s => s.Contains("Applying custom matcher to global settings"))); + } + + [Fact] + public async Task VariableSurvivesRecordPlaybackRoundtrip() + { + await DefaultHarness!.InitializeAsync(); + DefaultHarness.RegisterVariable("roundtrip", "value"); + await DefaultHarness.DisposeAsync(); + + var playbackHarness = new RecordedCommandTestHarness(CollectedOutput, Fixture) + { + DesiredMode = TestMode.Playback, + }; + await playbackHarness.InitializeAsync(); + Assert.True(playbackHarness.Variables.TryGetValue("roundtrip", out var variableValue)); + Assert.Equal("value", variableValue); + await playbackHarness.DisposeAsync(); + } + + public ValueTask InitializeAsync() + { + TestDisplayName = TestContext.Current?.Test?.TestCase?.TestCaseDisplayName ?? throw new InvalidDataException("Test case display name is not available."); + + var harness = new RecordedCommandTestHarness(CollectedOutput, Fixture) + { + DesiredMode = TestMode.Record + }; + + RecordingFileLocation = harness.GetRecordingAbsolutePath(TestDisplayName); + + if (File.Exists(RecordingFileLocation)) + { + File.Delete(RecordingFileLocation); + } + + DefaultHarness = harness; + return ValueTask.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + // always clean up this recording file on our way out of the test if it exists + if (File.Exists(RecordingFileLocation)) + { + File.Delete(RecordingFileLocation); + } + + // automatically collect the proxy fixture so that writers of tests don't need to remember to do so and the proxy process doesn't run forever + await Fixture.DisposeAsync(); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Services/Azure/Authentication/AuthenticationIntegrationTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Services/Azure/Authentication/AuthenticationIntegrationTests.cs index fefaec702e..1e67715024 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Services/Azure/Authentication/AuthenticationIntegrationTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.LiveTests/Services/Azure/Authentication/AuthenticationIntegrationTests.cs @@ -60,7 +60,7 @@ public async Task LoginWithIdentityBroker_ThenListSubscriptions_ShouldSucceed() // Step 2: Now test the subscription service which will use our CustomChainedCredential internally _output.WriteLine("Testing subscription listing with authenticated credential..."); - var subscriptions = await _subscriptionService.GetSubscriptions(); + var subscriptions = await _subscriptionService.GetSubscriptions(cancellationToken: TestContext.Current.CancellationToken); ValidateAndLogSubscriptions(subscriptions); } @@ -73,7 +73,7 @@ private static async Task AuthenticateWithBrokerAsync() // Verify the credential works by requesting a token var armScope = "https://management.azure.com/.default"; var context = new TokenRequestContext([armScope]); - var token = await browserCredential.GetTokenAsync(context); + var token = await browserCredential.GetTokenAsync(context, TestContext.Current.CancellationToken); Assert.NotNull(token.Token); Assert.NotEqual(default, token.ExpiresOn); diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Group/UnitTests/GroupListCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Group/UnitTests/GroupListCommandTests.cs index b677e47b43..0963859f3e 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Group/UnitTests/GroupListCommandTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Group/UnitTests/GroupListCommandTests.cs @@ -21,7 +21,7 @@ namespace Azure.Mcp.Core.UnitTests.Areas.Group.UnitTests; public class GroupListCommandTests { private readonly IServiceProvider _serviceProvider; - private readonly IMcpServer _mcpServer; + private readonly McpServer _mcpServer; private readonly ILogger _logger; private readonly IResourceGroupService _resourceGroupService; private readonly GroupListCommand _command; @@ -30,7 +30,7 @@ public class GroupListCommandTests public GroupListCommandTests() { - _mcpServer = Substitute.For(); + _mcpServer = Substitute.For(); _resourceGroupService = Substitute.For(); _logger = Substitute.For>(); var collection = new ServiceCollection() @@ -55,13 +55,13 @@ public async Task ExecuteAsync_WithValidSubscription_ReturnsResourceGroups() }; _resourceGroupService - .GetResourceGroups(Arg.Is(x => x == subscriptionId), Arg.Any(), Arg.Any()) + .GetResourceGroups(Arg.Is(x => x == subscriptionId), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(expectedGroups); var args = _commandDefinition.Parse($"--subscription {subscriptionId}"); // Act - var result = await _command.ExecuteAsync(_context, args); + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -87,7 +87,8 @@ public async Task ExecuteAsync_WithValidSubscription_ReturnsResourceGroups() await _resourceGroupService.Received(1).GetResourceGroups( Arg.Is(x => x == subscriptionId), Arg.Any(), - Arg.Any()); + Arg.Any(), + Arg.Any()); } [Fact] @@ -105,13 +106,14 @@ public async Task ExecuteAsync_WithTenant_PassesTenantToService() .GetResourceGroups( Arg.Is(x => x == subscriptionId), Arg.Is(x => x == tenantId), - Arg.Any()) + Arg.Any(), + Arg.Any()) .Returns(expectedGroups); var args = _commandDefinition.Parse($"--subscription {subscriptionId} --tenant {tenantId}"); // Act - var result = await _command.ExecuteAsync(_context, args); + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -119,7 +121,8 @@ public async Task ExecuteAsync_WithTenant_PassesTenantToService() await _resourceGroupService.Received(1).GetResourceGroups( Arg.Is(x => x == subscriptionId), Arg.Is(x => x == tenantId), - Arg.Any()); + Arg.Any(), + Arg.Any()); } [Fact] @@ -128,13 +131,13 @@ public async Task ExecuteAsync_EmptyResourceGroupList_ReturnsNullResults() // Arrange var subscriptionId = "test-subs-id"; _resourceGroupService - .GetResourceGroups(Arg.Any(), Arg.Any(), Arg.Any()) + .GetResourceGroups(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns([]); var args = _commandDefinition.Parse($"--subscription {subscriptionId}"); // Act - var result = await _command.ExecuteAsync(_context, args); + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -149,13 +152,13 @@ public async Task ExecuteAsync_ServiceThrowsException_ReturnsErrorInResponse() var subscriptionId = "test-subs-id"; var expectedError = "Test error message"; _resourceGroupService - .GetResourceGroups(Arg.Any(), Arg.Any(), Arg.Any()) + .GetResourceGroups(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromException>(new Exception(expectedError))); var args = _commandDefinition.Parse($"--subscription {subscriptionId}"); // Act - var result = await _command.ExecuteAsync(_context, args); + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/CommandFactoryHelpers.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/CommandFactoryHelpers.cs index 487c4782ce..6a40ec5181 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/CommandFactoryHelpers.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/CommandFactoryHelpers.cs @@ -3,16 +3,50 @@ using System.Diagnostics; using Azure.Mcp.Core.Areas; +using Azure.Mcp.Core.Areas.Group; using Azure.Mcp.Core.Areas.Subscription; using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Configuration; using Azure.Mcp.Core.Services.Telemetry; +using Azure.Mcp.Tools.Acr; +using Azure.Mcp.Tools.Aks; using Azure.Mcp.Tools.AppConfig; +using Azure.Mcp.Tools.AppLens; +using Azure.Mcp.Tools.Authorization; +using Azure.Mcp.Tools.AzureBestPractices; +using Azure.Mcp.Tools.AzureIsv; +using Azure.Mcp.Tools.AzureTerraformBestPractices; +using Azure.Mcp.Tools.BicepSchema; +using Azure.Mcp.Tools.CloudArchitect; +using Azure.Mcp.Tools.Cosmos; using Azure.Mcp.Tools.Deploy; +using Azure.Mcp.Tools.EventGrid; +using Azure.Mcp.Tools.Extension; +using Azure.Mcp.Tools.Foundry; +using Azure.Mcp.Tools.FunctionApp; +using Azure.Mcp.Tools.Grafana; using Azure.Mcp.Tools.KeyVault; +using Azure.Mcp.Tools.Kusto; +using Azure.Mcp.Tools.LoadTesting; +using Azure.Mcp.Tools.ManagedLustre; +using Azure.Mcp.Tools.Marketplace; +using Azure.Mcp.Tools.Monitor; +using Azure.Mcp.Tools.MySql; +using Azure.Mcp.Tools.Postgres; +using Azure.Mcp.Tools.Quota; +using Azure.Mcp.Tools.Redis; +using Azure.Mcp.Tools.ResourceHealth; +using Azure.Mcp.Tools.Search; +using Azure.Mcp.Tools.ServiceBus; +using Azure.Mcp.Tools.Sql; using Azure.Mcp.Tools.Storage; +using Azure.Mcp.Tools.VirtualDesktop; +using Azure.Mcp.Tools.Workbooks; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; namespace Azure.Mcp.Core.UnitTests.Areas.Server; @@ -21,17 +55,58 @@ internal class CommandFactoryHelpers public static CommandFactory CreateCommandFactory(IServiceProvider? serviceProvider = default) { IAreaSetup[] areaSetups = [ + // Core areas new SubscriptionSetup(), + new GroupSetup(), + + // Tool areas + new AcrSetup(), + new AksSetup(), + new AppConfigSetup(), + new AppLensSetup(), + new AuthorizationSetup(), + new AzureBestPracticesSetup(), + new AzureIsvSetup(), + new ManagedLustreSetup(), + new AzureTerraformBestPracticesSetup(), + new BicepSchemaSetup(), + new CloudArchitectSetup(), + new CosmosSetup(), + new DeploySetup(), + new EventGridSetup(), + new ExtensionSetup(), + new FoundrySetup(), + new FunctionAppSetup(), + new GrafanaSetup(), new KeyVaultSetup(), + new KustoSetup(), + new LoadTestingSetup(), + new MarketplaceSetup(), + new MonitorSetup(), + new MySqlSetup(), + new PostgresSetup(), + new QuotaSetup(), + new RedisSetup(), + new ResourceHealthSetup(), + new SearchSetup(), + new ServiceBusSetup(), + new SqlSetup(), new StorageSetup(), - new DeploySetup(), - new AppConfigSetup() + new VirtualDesktopSetup(), + new WorkbooksSetup(), ]; var services = serviceProvider ?? CreateDefaultServiceProvider(); var logger = services.GetRequiredService>(); + var configurationOptions = Microsoft.Extensions.Options.Options.Create(new AzureMcpServerConfiguration + { + Name = "Test Server", + Version = "Test Version", + DisplayName = "Test Display", + RootCommandGroupName = "azmcp" + }); var telemetryService = services.GetService() ?? new NoOpTelemetryService(); - var commandFactory = new CommandFactory(services, areaSetups, telemetryService, logger); + var commandFactory = new CommandFactory(services, areaSetups, telemetryService, configurationOptions, logger); return commandFactory; } @@ -44,11 +119,45 @@ public static IServiceProvider CreateDefaultServiceProvider() public static IServiceCollection SetupCommonServices() { IAreaSetup[] areaSetups = [ + // Core areas new SubscriptionSetup(), + new GroupSetup(), + + // Tool areas + new AcrSetup(), + new AksSetup(), + new AppConfigSetup(), + new AppLensSetup(), + new AuthorizationSetup(), + new AzureBestPracticesSetup(), + new AzureIsvSetup(), + new ManagedLustreSetup(), + new AzureTerraformBestPracticesSetup(), + new BicepSchemaSetup(), + new CloudArchitectSetup(), + new CosmosSetup(), + new DeploySetup(), + new EventGridSetup(), + new ExtensionSetup(), + new FoundrySetup(), + new FunctionAppSetup(), + new GrafanaSetup(), new KeyVaultSetup(), + new KustoSetup(), + new LoadTestingSetup(), + new MarketplaceSetup(), + new MonitorSetup(), + new MySqlSetup(), + new PostgresSetup(), + new QuotaSetup(), + new RedisSetup(), + new ResourceHealthSetup(), + new SearchSetup(), + new ServiceBusSetup(), + new SqlSetup(), new StorageSetup(), - new DeploySetup(), - new AppConfigSetup() + new VirtualDesktopSetup(), + new WorkbooksSetup(), ]; var builder = new ServiceCollection() @@ -65,18 +174,14 @@ public static IServiceCollection SetupCommonServices() public class NoOpTelemetryService : ITelemetryService { - public ValueTask StartActivity(string activityName) - { - return ValueTask.FromResult(null); - } + public Activity? StartActivity(string activityName) => null; - public ValueTask StartActivity(string activityName, Implementation? clientInfo) - { - return ValueTask.FromResult(null); - } + public Activity? StartActivity(string activityName, Implementation? clientInfo) => null; public void Dispose() { } + + public Task InitializeAsync() => Task.CompletedTask; } } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/BaseDiscoveryStrategyTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/BaseDiscoveryStrategyTests.cs index 74f4ab989d..280142c1a5 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/BaseDiscoveryStrategyTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/BaseDiscoveryStrategyTests.cs @@ -22,7 +22,7 @@ public TestDiscoveryStrategy(IEnumerable providers, ILogger? _providers = providers; } - public override Task> DiscoverServersAsync() + public override Task> DiscoverServersAsync(CancellationToken cancellationToken) { return Task.FromResult(_providers); } @@ -55,7 +55,7 @@ public async Task FindServerProvider_WithEmptyDiscovery_ThrowsArgumentException( var strategy = CreateMockStrategy(); // Act & Assert - var exception = await Assert.ThrowsAsync(() => strategy.FindServerProviderAsync("notfound")); + var exception = await Assert.ThrowsAsync(() => strategy.FindServerProviderAsync("notfound", TestContext.Current.CancellationToken)); Assert.Contains("notfound", exception.Message, StringComparison.OrdinalIgnoreCase); Assert.Contains("No MCP server found with the name", exception.Message); } @@ -69,7 +69,7 @@ public async Task FindServerProvider_WithNonExistentServer_ThrowsKeyNotFoundExce var strategy = CreateMockStrategy(provider1, provider2); // Act & Assert - var exception = await Assert.ThrowsAsync(() => strategy.FindServerProviderAsync("nonexistent")); + var exception = await Assert.ThrowsAsync(() => strategy.FindServerProviderAsync("nonexistent", TestContext.Current.CancellationToken)); Assert.Contains("nonexistent", exception.Message, StringComparison.OrdinalIgnoreCase); } @@ -82,7 +82,7 @@ public async Task FindServerProvider_WithExistingServer_ReturnsCorrectProvider() var strategy = CreateMockStrategy(provider1, provider2); // Act - var result = await strategy.FindServerProviderAsync("server1"); + var result = await strategy.FindServerProviderAsync("server1", TestContext.Current.CancellationToken); // Assert Assert.Same(provider1, result); @@ -96,7 +96,7 @@ public async Task FindServerProvider_WithCaseInsensitiveMatch_ReturnsCorrectProv var strategy = CreateMockStrategy(provider); // Act - var result3 = await strategy.FindServerProviderAsync("TestServer"); + var result3 = await strategy.FindServerProviderAsync("TestServer", TestContext.Current.CancellationToken); // Assert Assert.Same(provider, result3); @@ -112,7 +112,7 @@ public async Task FindServerProvider_WithMultipleServers_ReturnsCorrectOne() var strategy = CreateMockStrategy(provider1, provider2, provider3); // Act - var result = await strategy.FindServerProviderAsync("azure-keyvault"); + var result = await strategy.FindServerProviderAsync("azure-keyvault", TestContext.Current.CancellationToken); // Assert Assert.Same(provider2, result); @@ -122,31 +122,31 @@ public async Task FindServerProvider_WithMultipleServers_ReturnsCorrectOne() public async Task GetOrCreateClientAsync_WithNewServer_CreatesAndCachesClient() { // Arrange - var mockClient = Substitute.For(); + var mockClient = Substitute.For(); var provider = CreateMockServerProvider("TestServer"); - provider.CreateClientAsync(Arg.Any()).Returns(mockClient); + provider.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(mockClient); var strategy = CreateMockStrategy(provider); // Act - var result = await strategy.GetOrCreateClientAsync("TestServer"); + var result = await strategy.GetOrCreateClientAsync("TestServer", cancellationToken: TestContext.Current.CancellationToken); // Assert Assert.Same(mockClient, result); - await provider.Received(1).CreateClientAsync(Arg.Any()); + await provider.Received(1).CreateClientAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task GetOrCreateClientAsync_WithCachedServer_ReturnsCachedClient() { // Arrange - var mockClient = Substitute.For(); + var mockClient = Substitute.For(); var provider = CreateMockServerProvider("TestServer"); - provider.CreateClientAsync(Arg.Any()).Returns(mockClient); + provider.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(mockClient); var strategy = CreateMockStrategy(provider); // Act - var result1 = await strategy.GetOrCreateClientAsync("TestServer"); - var result2 = await strategy.GetOrCreateClientAsync("TestServer"); + var result1 = await strategy.GetOrCreateClientAsync("TestServer", cancellationToken: TestContext.Current.CancellationToken); + var result2 = await strategy.GetOrCreateClientAsync("TestServer", cancellationToken: TestContext.Current.CancellationToken); // Assert Assert.Same(mockClient, result1); @@ -154,42 +154,42 @@ public async Task GetOrCreateClientAsync_WithCachedServer_ReturnsCachedClient() Assert.Same(result1, result2); // Verify client was only created once - await provider.Received(1).CreateClientAsync(Arg.Any()); + await provider.Received(1).CreateClientAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task GetOrCreateClientAsync_WithCustomOptions_PassesOptionsCorrectly() { // Arrange - var mockClient = Substitute.For(); + var mockClient = Substitute.For(); var provider = CreateMockServerProvider("TestServer"); - provider.CreateClientAsync(Arg.Any()).Returns(mockClient); + provider.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(mockClient); var strategy = CreateMockStrategy(provider); var customOptions = new McpClientOptions { /* set custom properties if available */ }; // Act - var result = await strategy.GetOrCreateClientAsync("TestServer", customOptions); + var result = await strategy.GetOrCreateClientAsync("TestServer", customOptions, TestContext.Current.CancellationToken); // Assert Assert.Same(mockClient, result); - await provider.Received(1).CreateClientAsync(customOptions); + await provider.Received(1).CreateClientAsync(customOptions, Arg.Any()); } [Fact] public async Task GetOrCreateClientAsync_WithDefaultOptions_UsesDefaultOptions() { // Arrange - var mockClient = Substitute.For(); + var mockClient = Substitute.For(); var provider = CreateMockServerProvider("TestServer"); - provider.CreateClientAsync(Arg.Any()).Returns(mockClient); + provider.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(mockClient); var strategy = CreateMockStrategy(provider); // Act - var result = await strategy.GetOrCreateClientAsync("TestServer"); + var result = await strategy.GetOrCreateClientAsync("TestServer", cancellationToken: TestContext.Current.CancellationToken); // Assert Assert.Same(mockClient, result); - await provider.Received(1).CreateClientAsync(Arg.Is(opts => opts != null)); + await provider.Received(1).CreateClientAsync(Arg.Is(opts => opts != null), Arg.Any()); } [Fact] @@ -201,7 +201,7 @@ public async Task GetOrCreateClientAsync_WithNonExistentServer_ThrowsKeyNotFound // Act & Assert var exception = await Assert.ThrowsAsync( - () => strategy.GetOrCreateClientAsync("NonExistentServer")); + () => strategy.GetOrCreateClientAsync("NonExistentServer", cancellationToken: TestContext.Current.CancellationToken)); Assert.Contains("NonExistentServer", exception.Message, StringComparison.OrdinalIgnoreCase); } @@ -210,21 +210,21 @@ public async Task GetOrCreateClientAsync_WithNonExistentServer_ThrowsKeyNotFound public async Task GetOrCreateClientAsync_WithMultipleServers_CachesEachSeparately() { // Arrange - var mockClient1 = Substitute.For(); - var mockClient2 = Substitute.For(); + var mockClient1 = Substitute.For(); + var mockClient2 = Substitute.For(); var provider1 = CreateMockServerProvider("Server1"); var provider2 = CreateMockServerProvider("Server2"); - provider1.CreateClientAsync(Arg.Any()).Returns(mockClient1); - provider2.CreateClientAsync(Arg.Any()).Returns(mockClient2); + provider1.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(mockClient1); + provider2.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(mockClient2); var strategy = CreateMockStrategy(provider1, provider2); // Act - var result1a = await strategy.GetOrCreateClientAsync("Server1"); - var result2a = await strategy.GetOrCreateClientAsync("Server2"); - var result1b = await strategy.GetOrCreateClientAsync("Server1"); - var result2b = await strategy.GetOrCreateClientAsync("Server2"); + var result1a = await strategy.GetOrCreateClientAsync("Server1", cancellationToken: TestContext.Current.CancellationToken); + var result2a = await strategy.GetOrCreateClientAsync("Server2", cancellationToken: TestContext.Current.CancellationToken); + var result1b = await strategy.GetOrCreateClientAsync("Server1", cancellationToken: TestContext.Current.CancellationToken); + var result2b = await strategy.GetOrCreateClientAsync("Server2", cancellationToken: TestContext.Current.CancellationToken); // Assert Assert.Same(mockClient1, result1a); @@ -233,8 +233,8 @@ public async Task GetOrCreateClientAsync_WithMultipleServers_CachesEachSeparatel Assert.Same(result2a, result2b); // Verify each client was only created once - await provider1.Received(1).CreateClientAsync(Arg.Any()); - await provider2.Received(1).CreateClientAsync(Arg.Any()); + await provider1.Received(1).CreateClientAsync(Arg.Any(), Arg.Any()); + await provider2.Received(1).CreateClientAsync(Arg.Any(), Arg.Any()); } [Fact] @@ -245,7 +245,7 @@ public async Task FindServerProvider_WithNullName_ThrowsArgumentNullException() var strategy = CreateMockStrategy(provider); // Act & Assert - var exception = await Assert.ThrowsAsync(() => strategy.FindServerProviderAsync(null!)); + var exception = await Assert.ThrowsAsync(() => strategy.FindServerProviderAsync(null!, TestContext.Current.CancellationToken)); Assert.Equal("name", exception.ParamName); } @@ -257,7 +257,7 @@ public async Task FindServerProvider_WithEmptyName_ThrowsArgumentNullException() var strategy = CreateMockStrategy(provider); // Act & Assert - var exception = await Assert.ThrowsAsync(() => strategy.FindServerProviderAsync("")); + var exception = await Assert.ThrowsAsync(() => strategy.FindServerProviderAsync("", TestContext.Current.CancellationToken)); Assert.Equal("name", exception.ParamName); Assert.Contains("Server name cannot be null or empty", exception.Message); } @@ -271,7 +271,7 @@ public async Task GetOrCreateClientAsync_WithNullName_ThrowsArgumentNullExceptio // Act & Assert var exception = await Assert.ThrowsAsync( - () => strategy.GetOrCreateClientAsync(null!)); + () => strategy.GetOrCreateClientAsync(null!, cancellationToken: TestContext.Current.CancellationToken)); Assert.Equal("name", exception.ParamName); } @@ -285,7 +285,7 @@ public async Task GetOrCreateClientAsync_WithEmptyName_ThrowsArgumentNullExcepti // Act & Assert var exception = await Assert.ThrowsAsync( - () => strategy.GetOrCreateClientAsync("")); + () => strategy.GetOrCreateClientAsync("", cancellationToken: TestContext.Current.CancellationToken)); Assert.Equal("name", exception.ParamName); Assert.Contains("Server name cannot be null or empty", exception.Message); @@ -295,50 +295,50 @@ public async Task GetOrCreateClientAsync_WithEmptyName_ThrowsArgumentNullExcepti public async Task GetOrCreateClientAsync_CacheUsesSameKeyForDifferentCasing_ReusesCachedClient() { // Arrange - var mockClient1 = Substitute.For(); + var mockClient1 = Substitute.For(); var provider = CreateMockServerProvider("TestServer"); // Setup provider to return a client for the first call - provider.CreateClientAsync(Arg.Any()) + provider.CreateClientAsync(Arg.Any(), Arg.Any()) .Returns(mockClient1); var strategy = CreateMockStrategy(provider); // Act - Different casings use the same cache key because we use StringComparer.OrdinalIgnoreCase - var result1 = await strategy.GetOrCreateClientAsync("TestServer"); + var result1 = await strategy.GetOrCreateClientAsync("TestServer", cancellationToken: TestContext.Current.CancellationToken); // Assert - Same client because cache keys are case-insensitive Assert.Same(mockClient1, result1); // Verify provider was called only once (the same cached client is returned for all casing variants) - await provider.Received(1).CreateClientAsync(Arg.Any()); + await provider.Received(1).CreateClientAsync(Arg.Any(), Arg.Any()); // Verify subsequent calls with any casing return the same cached client - var result1b = await strategy.GetOrCreateClientAsync("TestServer"); + var result1b = await strategy.GetOrCreateClientAsync("TestServer", cancellationToken: TestContext.Current.CancellationToken); Assert.Same(result1, result1b); // Still only 1 call total (all calls use the cached entry regardless of casing) - await provider.Received(1).CreateClientAsync(Arg.Any()); + await provider.Received(1).CreateClientAsync(Arg.Any(), Arg.Any()); } [Fact] public async Task DisposeAsync_ShouldDisposeAllCachedClients() { // Arrange - var mockClient1 = Substitute.For(); - var mockClient2 = Substitute.For(); + var mockClient1 = Substitute.For(); + var mockClient2 = Substitute.For(); var provider1 = CreateMockServerProvider("Server1"); var provider2 = CreateMockServerProvider("Server2"); - provider1.CreateClientAsync(Arg.Any()).Returns(mockClient1); - provider2.CreateClientAsync(Arg.Any()).Returns(mockClient2); + provider1.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(mockClient1); + provider2.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(mockClient2); var strategy = CreateMockStrategy(provider1, provider2); // Create and cache some clients - await strategy.GetOrCreateClientAsync("Server1"); - await strategy.GetOrCreateClientAsync("Server2"); + await strategy.GetOrCreateClientAsync("Server1", cancellationToken: TestContext.Current.CancellationToken); + await strategy.GetOrCreateClientAsync("Server2", cancellationToken: TestContext.Current.CancellationToken); // Act await strategy.DisposeAsync(); @@ -363,13 +363,13 @@ public async Task DisposeAsync_WithNoCachedClients_ShouldNotThrow() public async Task DisposeAsync_ShouldHandleClientDisposalExceptions() { // Arrange - var mockClient1 = Substitute.For(); - var mockClient2 = Substitute.For(); + var mockClient1 = Substitute.For(); + var mockClient2 = Substitute.For(); var provider1 = CreateMockServerProvider("Server1"); var provider2 = CreateMockServerProvider("Server2"); - provider1.CreateClientAsync(Arg.Any()).Returns(mockClient1); - provider2.CreateClientAsync(Arg.Any()).Returns(mockClient2); + provider1.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(mockClient1); + provider2.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(mockClient2); // Setup first client to throw on disposal mockClient1.DisposeAsync().Returns(ValueTask.FromException(new InvalidOperationException("Client 1 disposal failed"))); @@ -378,8 +378,8 @@ public async Task DisposeAsync_ShouldHandleClientDisposalExceptions() var strategy = CreateMockStrategy(provider1, provider2); // Cache both clients - await strategy.GetOrCreateClientAsync("Server1"); - await strategy.GetOrCreateClientAsync("Server2"); + await strategy.GetOrCreateClientAsync("Server1", cancellationToken: TestContext.Current.CancellationToken); + await strategy.GetOrCreateClientAsync("Server2", cancellationToken: TestContext.Current.CancellationToken); // Act - Should not throw (BaseDiscoveryStrategy catches and swallows disposal exceptions) await strategy.DisposeAsync(); @@ -393,14 +393,14 @@ public async Task DisposeAsync_ShouldHandleClientDisposalExceptions() public async Task DisposeAsync_ShouldBeIdempotent() { // Arrange - var mockClient = Substitute.For(); + var mockClient = Substitute.For(); var provider = CreateMockServerProvider("Server1"); - provider.CreateClientAsync(Arg.Any()).Returns(mockClient); + provider.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(mockClient); var strategy = CreateMockStrategy(provider); // Cache a client - await strategy.GetOrCreateClientAsync("Server1"); + await strategy.GetOrCreateClientAsync("Server1", cancellationToken: TestContext.Current.CancellationToken); // Act - dispose multiple times await strategy.DisposeAsync(); @@ -415,14 +415,14 @@ public async Task DisposeAsync_ShouldBeIdempotent() public async Task DisposeAsync_ShouldClearClientCache() { // Arrange - var mockClient = Substitute.For(); + var mockClient = Substitute.For(); var provider = CreateMockServerProvider("Server1"); - provider.CreateClientAsync(Arg.Any()).Returns(mockClient); + provider.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(mockClient); var strategy = CreateMockStrategy(provider); // Cache a client - var client1 = await strategy.GetOrCreateClientAsync("Server1"); + var client1 = await strategy.GetOrCreateClientAsync("Server1", cancellationToken: TestContext.Current.CancellationToken); Assert.Same(mockClient, client1); // Act diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategyTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategyTests.cs index 81c09dbc2a..7d752d2a86 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategyTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategyTests.cs @@ -130,7 +130,7 @@ public async Task DiscoverServersAsync_WithDefaultOptions_ReturnsNonEmptyCollect var strategy = CreateStrategy(); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -146,7 +146,7 @@ public async Task DiscoverServersAsync_WithReadOnlyFalse_CreatesNonReadOnlyProvi var strategy = CreateStrategy(options: options); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert Assert.NotEmpty(result); @@ -162,7 +162,7 @@ public async Task DiscoverServersAsync_WithReadOnlyTrue_CreatesReadOnlyProviders var strategy = CreateStrategy(options: options); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert Assert.NotEmpty(result); @@ -178,7 +178,7 @@ public async Task DiscoverServersAsync_WithNullReadOnlyOption_DefaultsToFalse() var strategy = CreateStrategy(options: options); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert Assert.NotEmpty(result); @@ -194,7 +194,7 @@ public async Task DiscoverServersAsync_WithCustomEntryPoint_SetsEntryPointOnAllP var strategy = CreateStrategy(entryPoint: customEntryPoint); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert Assert.NotEmpty(result); @@ -209,7 +209,7 @@ public async Task DiscoverServersAsync_WithNullEntryPoint_UsesCurrentProcessExec var strategy = CreateStrategy(entryPoint: null); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert Assert.NotEmpty(result); @@ -231,7 +231,7 @@ public async Task DiscoverServersAsync_WithEmptyEntryPoint_ProvidersDefaultToCur var strategy = CreateStrategy(entryPoint: ""); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert Assert.NotEmpty(result); @@ -251,7 +251,7 @@ public async Task DiscoverServersAsync_WithWhitespaceEntryPoint_ProvidersDefault var strategy = CreateStrategy(entryPoint: " "); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert Assert.NotEmpty(result); @@ -271,11 +271,11 @@ public async Task DiscoverServersAsync_ExcludesIgnoredGroups() var strategy = CreateStrategy(); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert var names = result.Select(p => p.CreateMetadata().Name).ToList(); - var ignoredGroups = new[] { "extension", "server", "tools" }; + var ignoredGroups = DiscoveryConstants.IgnoredCommandGroups; foreach (var ignored in ignoredGroups) { @@ -290,7 +290,7 @@ public async Task DiscoverServersAsync_EachProviderHasCorrectMetadata() var strategy = CreateStrategy(); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert var providers = result.ToList(); @@ -314,7 +314,7 @@ public async Task DiscoverServersAsync_ProvidersAreCommandGroupServerProviderTyp var strategy = CreateStrategy(); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert Assert.NotEmpty(result); @@ -328,7 +328,7 @@ public async Task DiscoverServersAsync_ProvidersHaveUniqueNames() var strategy = CreateStrategy(); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert var providers = result.ToList(); @@ -343,8 +343,8 @@ public async Task DiscoverServersAsync_CanBeCalledMultipleTimes() var strategy = CreateStrategy(); // Act - var result1 = await strategy.DiscoverServersAsync(); - var result2 = await strategy.DiscoverServersAsync(); + var result1 = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + var result2 = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert Assert.NotNull(result1); @@ -367,7 +367,7 @@ public async Task DiscoverServersAsync_WithRealCommandFactory_IncludesKnownGroup var strategy = CreateStrategy(); // Uses real command factory // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert var providers = result.ToList(); @@ -377,7 +377,7 @@ public async Task DiscoverServersAsync_WithRealCommandFactory_IncludesKnownGroup Assert.Contains("storage", names, StringComparer.OrdinalIgnoreCase); // Should not include ignored groups - var ignoredGroups = new[] { "extension", "server", "tools" }; + var ignoredGroups = DiscoveryConstants.IgnoredCommandGroups; foreach (var ignored in ignoredGroups) { Assert.DoesNotContain(ignored, names, StringComparer.OrdinalIgnoreCase); @@ -396,7 +396,7 @@ public async Task DiscoverServersAsync_RespectsServiceStartOptionsValues() var strategy = CreateStrategy(options: options, entryPoint: azmcpEntryPoint); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert Assert.NotEmpty(result); @@ -415,15 +415,16 @@ public async Task DiscoverServersAsync_IgnoredGroupsAreCaseInsensitive() var strategy = CreateStrategy(); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert var names = result.Select(p => p.CreateMetadata().Name).ToList(); // Verify ignored groups are not present (case insensitive) - Assert.DoesNotContain("extension", names, StringComparer.OrdinalIgnoreCase); - Assert.DoesNotContain("server", names, StringComparer.OrdinalIgnoreCase); - Assert.DoesNotContain("tools", names, StringComparer.OrdinalIgnoreCase); + foreach (var ignored in DiscoveryConstants.IgnoredCommandGroups) + { + Assert.DoesNotContain(ignored, names, StringComparer.OrdinalIgnoreCase); + } } [Fact] @@ -433,8 +434,8 @@ public async Task DiscoverServersAsync_ResultCountIsConsistent() var strategy = CreateStrategy(); // Act - var result1 = await strategy.DiscoverServersAsync(); - var result2 = await strategy.DiscoverServersAsync(); + var result1 = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + var result2 = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert var count1 = result1.Count(); @@ -451,7 +452,7 @@ public async Task ShouldDiscoverServers() var options = Microsoft.Extensions.Options.Options.Create(new ServiceStartOptions()); var logger = NSubstitute.Substitute.For>(); var strategy = new CommandGroupDiscoveryStrategy(commandFactory, options, logger); - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); Assert.NotNull(result); } @@ -466,10 +467,10 @@ public async Task ShouldDiscoverServers_ExcludesIgnoredGroupsAndSetsProperties() { EntryPoint = azmcpEntryPoint }; - var result = (await strategy.DiscoverServersAsync()).ToList(); + var result = (await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken)).ToList(); Assert.NotEmpty(result); // Should not include ignored groups - var ignored = new[] { "extension", "server", "tools" }; + var ignored = DiscoveryConstants.IgnoredCommandGroups; Assert.DoesNotContain(result, p => ignored.Contains(p.CreateMetadata().Name, StringComparer.OrdinalIgnoreCase)); // Should include at least one known group (e.g. storage) Assert.Contains(result, p => p.CreateMetadata().Name == "storage"); @@ -521,7 +522,7 @@ public async Task DiscoverServersAsync_WithNamespaceFilter_ReturnsOnlySpecifiedN var strategy = CreateStrategy(options: options); // Act - var servers = await strategy.DiscoverServersAsync(); + var servers = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); var serverNames = servers.Select(s => s.CreateMetadata().Name).ToList(); // Assert @@ -546,7 +547,7 @@ public async Task DiscoverServersAsync_WithEmptyNamespaceFilter_ReturnsAllNamesp var strategy = CreateStrategy(options: options); // Act - var servers = await strategy.DiscoverServersAsync(); + var servers = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); var serverNames = servers.Select(s => s.CreateMetadata().Name).ToList(); // Assert @@ -558,6 +559,8 @@ public async Task DiscoverServersAsync_WithEmptyNamespaceFilter_ReturnsAllNamesp Assert.Contains("keyvault", serverNames); Assert.DoesNotContain("server", serverNames); // Should be ignored Assert.DoesNotContain("extension", serverNames); // Should be ignored + Assert.DoesNotContain("subscription", serverNames); // Should be ignored + Assert.DoesNotContain("group", serverNames); // Should be ignored } [Fact] @@ -571,7 +574,7 @@ public async Task DiscoverServersAsync_WithNullNamespaceFilter_ReturnsAllNamespa var strategy = CreateStrategy(options: options); // Act - var servers = await strategy.DiscoverServersAsync(); + var servers = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); var serverNames = servers.Select(s => s.CreateMetadata().Name).ToList(); // Assert @@ -583,6 +586,8 @@ public async Task DiscoverServersAsync_WithNullNamespaceFilter_ReturnsAllNamespa Assert.Contains("keyvault", serverNames); Assert.DoesNotContain("server", serverNames); // Should be ignored Assert.DoesNotContain("extension", serverNames); // Should be ignored + Assert.DoesNotContain("subscription", serverNames); // Should be ignored + Assert.DoesNotContain("group", serverNames); // Should be ignored } } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/CommandGroupServerProviderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/CommandGroupServerProviderTests.cs index c2bf2096df..39694d2f13 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/CommandGroupServerProviderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/CommandGroupServerProviderTests.cs @@ -51,7 +51,7 @@ public async Task CreateClientAsync_ReturnsClientInstance() var options = new McpClientOptions(); // Act - var client = await mcpCommandGroup.CreateClientAsync(options); + var client = await mcpCommandGroup.CreateClientAsync(options, TestContext.Current.CancellationToken); // Assert Assert.NotNull(client); diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/CompositeDiscoveryStrategyTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/CompositeDiscoveryStrategyTests.cs index 917fddc2b5..b3eb1de9e6 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/CompositeDiscoveryStrategyTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/CompositeDiscoveryStrategyTests.cs @@ -12,7 +12,7 @@ public class CompositeDiscoveryStrategyTests private static IMcpDiscoveryStrategy CreateMockStrategy(params IMcpServerProvider[] providers) { var strategy = Substitute.For(); - strategy.DiscoverServersAsync().Returns(Task.FromResult>(providers)); + strategy.DiscoverServersAsync(TestContext.Current.CancellationToken).Returns(Task.FromResult>(providers)); return strategy; } @@ -92,7 +92,7 @@ public async Task DiscoverServersAsync_WithSingleStrategy_ReturnsProvidersFromTh var composite = CreateCompositeStrategy(new[] { strategy }); // Act - var result = (await composite.DiscoverServersAsync()).ToList(); + var result = (await composite.DiscoverServersAsync(TestContext.Current.CancellationToken)).ToList(); // Assert Assert.Equal(2, result.Count); @@ -114,7 +114,7 @@ public async Task DiscoverServersAsync_WithMultipleStrategies_AggregatesAllResul var composite = CreateCompositeStrategy(new[] { strategy1, strategy2 }); // Act - var result = (await composite.DiscoverServersAsync()).ToList(); + var result = (await composite.DiscoverServersAsync(TestContext.Current.CancellationToken)).ToList(); // Assert Assert.Equal(4, result.Count); @@ -136,7 +136,7 @@ public async Task DiscoverServersAsync_WithStrategiesReturningEmpty_HandlesGrace var composite = CreateCompositeStrategy(new[] { activeStrategy, emptyStrategy1, emptyStrategy2 }); // Act - var result = (await composite.DiscoverServersAsync()).ToList(); + var result = (await composite.DiscoverServersAsync(TestContext.Current.CancellationToken)).ToList(); // Assert Assert.Single(result); @@ -152,7 +152,7 @@ public async Task DiscoverServersAsync_WithAllEmptyStrategies_ReturnsEmptyCollec var composite = CreateCompositeStrategy(new[] { emptyStrategy1, emptyStrategy2 }); // Act - var result = await composite.DiscoverServersAsync(); + var result = await composite.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -174,7 +174,7 @@ public async Task DiscoverServersAsync_ExecutesAllStrategiesInParallel() var composite = CreateCompositeStrategy(new[] { strategy1, strategy2, strategy3 }); // Act - var result = (await composite.DiscoverServersAsync()).ToList(); + var result = (await composite.DiscoverServersAsync(TestContext.Current.CancellationToken)).ToList(); // Assert Assert.Equal(3, result.Count); @@ -183,9 +183,9 @@ public async Task DiscoverServersAsync_ExecutesAllStrategiesInParallel() Assert.Contains(provider3, result); // Verify all strategies were called - await strategy1.Received(1).DiscoverServersAsync(); - await strategy2.Received(1).DiscoverServersAsync(); - await strategy3.Received(1).DiscoverServersAsync(); + await strategy1.Received(1).DiscoverServersAsync(Arg.Any()); + await strategy2.Received(1).DiscoverServersAsync(Arg.Any()); + await strategy3.Received(1).DiscoverServersAsync(Arg.Any()); } [Fact] @@ -203,7 +203,7 @@ public async Task DiscoverServersAsync_PreservesOrderFromStrategies() var composite = CreateCompositeStrategy(new[] { strategy1, strategy2 }); // Act - var result = (await composite.DiscoverServersAsync()).ToList(); + var result = (await composite.DiscoverServersAsync(TestContext.Current.CancellationToken)).ToList(); // Assert Assert.Equal(4, result.Count); @@ -224,8 +224,8 @@ public async Task DiscoverServersAsync_CanBeCalledMultipleTimes() var composite = CreateCompositeStrategy(new[] { strategy }); // Act - var result1 = (await composite.DiscoverServersAsync()).ToList(); - var result2 = (await composite.DiscoverServersAsync()).ToList(); + var result1 = (await composite.DiscoverServersAsync(TestContext.Current.CancellationToken)).ToList(); + var result2 = (await composite.DiscoverServersAsync(TestContext.Current.CancellationToken)).ToList(); // Assert Assert.Equal(result1.Count, result2.Count); @@ -233,7 +233,7 @@ public async Task DiscoverServersAsync_CanBeCalledMultipleTimes() Assert.Equal(2, result2.Count); // Should call the underlying strategy each time - await strategy.Received(2).DiscoverServersAsync(); + await strategy.Received(2).DiscoverServersAsync(Arg.Any()); } [Fact] @@ -249,7 +249,7 @@ public async Task DiscoverServersAsync_WithDuplicateProviders_IncludesAllProvide var composite = CreateCompositeStrategy(new[] { strategy1, strategy2 }); // Act - var result = (await composite.DiscoverServersAsync()).ToList(); + var result = (await composite.DiscoverServersAsync(TestContext.Current.CancellationToken)).ToList(); // Assert // CompositeDiscoveryStrategy doesn't deduplicate - it includes all providers @@ -269,7 +269,7 @@ public async Task DiscoverServersAsync_InheritsFromBaseDiscoveryStrategy() Assert.IsAssignableFrom(composite); // Should implement the base contract - var result = await composite.DiscoverServersAsync(); + var result = await composite.DiscoverServersAsync(TestContext.Current.CancellationToken); Assert.NotNull(result); } @@ -281,10 +281,10 @@ public async Task ShouldAggregateResults() var mockStrategy2 = Substitute.For(); var provider1 = Substitute.For(); var provider2 = Substitute.For(); - mockStrategy1.DiscoverServersAsync().Returns(Task.FromResult>(new[] { provider1 })); - mockStrategy2.DiscoverServersAsync().Returns(Task.FromResult>(new[] { provider2 })); + mockStrategy1.DiscoverServersAsync(TestContext.Current.CancellationToken).Returns(Task.FromResult>(new[] { provider1 })); + mockStrategy2.DiscoverServersAsync(TestContext.Current.CancellationToken).Returns(Task.FromResult>(new[] { provider2 })); var composite = CreateCompositeStrategy(new[] { mockStrategy1, mockStrategy2 }); - var result = await composite.DiscoverServersAsync(); + var result = await composite.DiscoverServersAsync(TestContext.Current.CancellationToken); Assert.Contains(provider1, result); Assert.Contains(provider2, result); } @@ -298,10 +298,10 @@ public async Task ShouldAggregateResults_ReturnsAllProviders() var provider2 = Substitute.For(); provider1.CreateMetadata().Returns(new McpServerMetadata { Id = "one", Name = "one", Description = "desc1" }); provider2.CreateMetadata().Returns(new McpServerMetadata { Id = "two", Name = "two", Description = "desc2" }); - mockStrategy1.DiscoverServersAsync().Returns(Task.FromResult>(new[] { provider1 })); - mockStrategy2.DiscoverServersAsync().Returns(Task.FromResult>(new[] { provider2 })); + mockStrategy1.DiscoverServersAsync(TestContext.Current.CancellationToken).Returns(Task.FromResult>(new[] { provider1 })); + mockStrategy2.DiscoverServersAsync(TestContext.Current.CancellationToken).Returns(Task.FromResult>(new[] { provider2 })); var composite = CreateCompositeStrategy(new[] { mockStrategy1, mockStrategy2 }); - var result = (await composite.DiscoverServersAsync()).ToList(); + var result = (await composite.DiscoverServersAsync(TestContext.Current.CancellationToken)).ToList(); Assert.Equal(2, result.Count); Assert.Contains(result, p => p.CreateMetadata().Id == "one"); Assert.Contains(result, p => p.CreateMetadata().Id == "two"); @@ -315,7 +315,7 @@ public async Task DiscoverServersAsync_WithSingleEmptyStrategy_ReturnsEmptyColle var composite = CreateCompositeStrategy(new[] { emptyStrategy }); // Act - var result = await composite.DiscoverServersAsync(); + var result = await composite.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/ConsolidatedToolDiscoveryStrategyTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/ConsolidatedToolDiscoveryStrategyTests.cs new file mode 100644 index 0000000000..1f2f973777 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/ConsolidatedToolDiscoveryStrategyTests.cs @@ -0,0 +1,119 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Areas.Server.Commands.Discovery; +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.Discovery; + +public class ConsolidatedToolDiscoveryStrategyTests +{ + private static ConsolidatedToolDiscoveryStrategy CreateStrategy( + CommandFactory? commandFactory = null, + ServiceStartOptions? options = null, + string? entryPoint = null) + { + var factory = commandFactory ?? CommandFactoryHelpers.CreateCommandFactory(); + var serviceProvider = CommandFactoryHelpers.SetupCommonServices().BuildServiceProvider(); + var startOptions = Microsoft.Extensions.Options.Options.Create(options ?? new ServiceStartOptions()); + var configurationOptions = Microsoft.Extensions.Options.Options.Create(new AzureMcpServerConfiguration + { + Name = "Test Server", + Version = "Test Version", + DisplayName = "Test Display", + RootCommandGroupName = "azmcp" + }); + var logger = NSubstitute.Substitute.For>(); + var strategy = new ConsolidatedToolDiscoveryStrategy(factory, serviceProvider, startOptions, configurationOptions, logger); + if (entryPoint != null) + { + strategy.EntryPoint = entryPoint; + } + return strategy; + } + + [Fact] + public async Task DiscoverServersAsync_ReturnsEmptyList() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + var providers = result.ToList(); + Assert.Empty(providers); + } + + [Fact] + public void CreateConsolidatedCommandFactory_WithDefaultOptions_ReturnsCommandFactory() + { + // Arrange + var strategy = CreateStrategy(); + + // Act + var factory = strategy.CreateConsolidatedCommandFactory(); + + // Assert + Assert.NotNull(factory); + Assert.True(factory.AllCommands.Count > 10); + } + + [Fact] + public void CreateConsolidatedCommandFactory_WithNamespaceFilter_FiltersCommands() + { + // Arrange + var options = new ServiceStartOptions { Namespace = ["storage"] }; + var strategy = CreateStrategy(options: options); + + // Act + var factory = strategy.CreateConsolidatedCommandFactory(); + + // Assert + Assert.NotNull(factory); + // Should only have storage-related consolidated commands + var allCommands = factory.AllCommands; + Assert.True(allCommands.Count > 0); + Assert.True(allCommands.Count < 10); + } + + [Fact] + public void CreateConsolidatedCommandFactory_WithReadOnlyFilter_FiltersCommands() + { + // Arrange + var options = new ServiceStartOptions { ReadOnly = true }; + var strategy = CreateStrategy(options: options); + + // Act + var factory = strategy.CreateConsolidatedCommandFactory(); + + // Assert + Assert.NotNull(factory); + var allCommands = factory.AllCommands; + Assert.True(allCommands.Count > 0); + // All commands should be read-only + Assert.All(allCommands.Values, cmd => Assert.True(cmd.Metadata.ReadOnly)); + } + + [Fact] + public void CreateConsolidatedCommandFactory_HandlesEmptyNamespaceFilter() + { + // Arrange + var options = new ServiceStartOptions { Namespace = [] }; + var strategy = CreateStrategy(options: options); + + // Act + var factory = strategy.CreateConsolidatedCommandFactory(); + + // Assert + Assert.NotNull(factory); + var allCommands = factory.AllCommands; + Assert.True(allCommands.Count > 0); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategyTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategyTests.cs index c7e72ebc30..ab622f9de8 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategyTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategyTests.cs @@ -34,7 +34,7 @@ public async Task DiscoverServersAsync_ReturnsNonNullResult() var strategy = CreateStrategy(); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -47,7 +47,7 @@ public async Task DiscoverServersAsync_ReturnsExpectedProviders() var strategy = CreateStrategy(); // Act - var result = (await strategy.DiscoverServersAsync()).ToList(); + var result = (await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken)).ToList(); // Assert Assert.NotEmpty(result); @@ -69,7 +69,7 @@ public async Task DiscoverServersAsync_AllProvidersAreRegistryServerProviderType var strategy = CreateStrategy(); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert Assert.NotEmpty(result); @@ -83,7 +83,7 @@ public async Task DiscoverServersAsync_EachProviderHasValidMetadata() var strategy = CreateStrategy(); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert var providers = result.ToList(); @@ -108,7 +108,7 @@ public async Task DiscoverServersAsync_ProvidersHaveUniqueIds() var strategy = CreateStrategy(); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert var providers = result.ToList(); @@ -123,8 +123,8 @@ public async Task DiscoverServersAsync_CanBeCalledMultipleTimes() var strategy = CreateStrategy(); // Act - var result1 = await strategy.DiscoverServersAsync(); - var result2 = await strategy.DiscoverServersAsync(); + var result1 = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + var result2 = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert Assert.NotNull(result1); @@ -147,8 +147,8 @@ public async Task DiscoverServersAsync_ResultCountIsConsistent() var strategy = CreateStrategy(); // Act - var result1 = await strategy.DiscoverServersAsync(); - var result2 = await strategy.DiscoverServersAsync(); + var result1 = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); + var result2 = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert var count1 = result1.Count(); @@ -164,7 +164,7 @@ public async Task DiscoverServersAsync_LoadsFromEmbeddedRegistryResource() var strategy = CreateStrategy(); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert // Should successfully load from the embedded registry.json resource @@ -182,7 +182,7 @@ public async Task DiscoverServersAsync_DocumentationServerHasExpectedProperties( var strategy = CreateStrategy(); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); var documentationProvider = result.FirstOrDefault(p => p.CreateMetadata().Name == "documentation"); // Assert @@ -206,7 +206,7 @@ public async Task DiscoverServersAsync_ServerNamesMatchIds() var strategy = CreateStrategy(); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert Assert.NotEmpty(result); @@ -226,7 +226,7 @@ public async Task DiscoverServersAsync_AllProvidersCanCreateMetadata() var strategy = CreateStrategy(); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert Assert.NotEmpty(result); @@ -249,7 +249,7 @@ public async Task DiscoverServersAsync_RegistryServerProviderSupportsSSE() var strategy = CreateStrategy(); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); var documentationProvider = result.FirstOrDefault(p => p.CreateMetadata().Name == "documentation"); // Assert @@ -272,7 +272,7 @@ public async Task DiscoverServersAsync_RegistryServersHaveValidDescriptions() var strategy = CreateStrategy(); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert Assert.NotEmpty(result); @@ -296,7 +296,7 @@ public async Task DiscoverServersAsync_InheritsFromBaseDiscoveryStrategy() Assert.IsAssignableFrom(strategy); // Should implement the base contract - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); Assert.NotNull(result); } @@ -305,7 +305,7 @@ public async Task DiscoverServersAsync_InheritsFromBaseDiscoveryStrategy() public async Task ShouldDiscoverServers() { var strategy = CreateStrategy(); - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); Assert.NotNull(result); } @@ -313,7 +313,7 @@ public async Task ShouldDiscoverServers() public async Task ShouldDiscoverServers_ReturnsExpectedProviders() { var strategy = CreateStrategy(); - var result = (await strategy.DiscoverServersAsync()).ToList(); + var result = (await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken)).ToList(); Assert.NotEmpty(result); // Should contain the 'documentation' server from registry.json var documentationProvider = result.FirstOrDefault(p => p.CreateMetadata().Name == "documentation"); @@ -333,7 +333,7 @@ public async Task DiscoverServersAsync_WithNullNamespace_ReturnsAllServers() var strategy = CreateStrategy(options); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert Assert.NotEmpty(result); @@ -350,7 +350,7 @@ public async Task DiscoverServersAsync_WithEmptyNamespace_ReturnsAllServers() var strategy = CreateStrategy(options); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert Assert.NotEmpty(result); @@ -367,7 +367,7 @@ public async Task DiscoverServersAsync_WithMatchingNamespace_ReturnsFilteredServ var strategy = CreateStrategy(options); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert var providers = result.ToList(); @@ -389,7 +389,7 @@ public async Task DiscoverServersAsync_WithNonMatchingNamespace_ReturnsEmptyResu var strategy = CreateStrategy(options); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert var providers = result.ToList(); @@ -404,7 +404,7 @@ public async Task DiscoverServersAsync_WithMultipleNamespaces_ReturnsMatchingSer var strategy = CreateStrategy(options); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert var providers = result.ToList(); @@ -427,7 +427,7 @@ public async Task DiscoverServersAsync_NamespaceFilteringIsCaseInsensitive() var strategy = CreateStrategy(options); // Act - var result = await strategy.DiscoverServersAsync(); + var result = await strategy.DiscoverServersAsync(TestContext.Current.CancellationToken); // Assert var providers = result.ToList(); diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryServerProviderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryServerProviderTests.cs index 6e51f48022..ec3129e792 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryServerProviderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryServerProviderTests.cs @@ -48,6 +48,7 @@ public void CreateMetadata_ReturnsExpectedMetadata() Assert.NotNull(metadata); Assert.Equal(testId, metadata.Id); Assert.Equal(testId, metadata.Name); + Assert.Null(metadata.Title); Assert.Equal(serverInfo.Description, metadata.Description); } @@ -69,29 +70,54 @@ public void CreateMetadata_EmptyDescription_ReturnsEmptyString() Assert.NotNull(metadata); Assert.Equal(testId, metadata.Id); Assert.Equal(testId, metadata.Name); + Assert.Null(metadata.Title); // No title specified Assert.Equal(string.Empty, metadata.Description); } [Fact] - public async Task CreateClientAsync_WithUrlReturning404_ThrowsHttpRequestException() + public void CreateMetadata_WithTitle_ReturnsTitleInMetadata() { // Arrange - string testId = "sseProvider"; - using var server = new MockHttpTestServer(); + string testId = "testProvider"; + string testTitle = "Test Provider Display Name"; var serverInfo = new RegistryServerInfo { - Description = "Test SSE Provider", - Url = $"{server.Endpoint}/mcp" + Title = testTitle, + Description = "Test Description" }; var provider = new RegistryServerProvider(testId, serverInfo); - // Act & Assert - var exception = await Assert.ThrowsAsync( - () => provider.CreateClientAsync(new McpClientOptions())); + // Act + var metadata = provider.CreateMetadata(); - Assert.Contains(((int)HttpStatusCode.NotFound).ToString(), exception.Message); + // Assert + Assert.NotNull(metadata); + Assert.Equal(testId, metadata.Id); + Assert.Equal(testId, metadata.Name); + Assert.Equal(testTitle, metadata.Title); + Assert.Equal(serverInfo.Description, metadata.Description); } + // [Fact] + // public async Task CreateClientAsync_WithUrlReturning404_ThrowsHttpRequestException() + // { + // // Arrange + // string testId = "sseProvider"; + // using var server = new MockHttpTestServer(); + // var serverInfo = new RegistryServerInfo + // { + // Description = "Test SSE Provider", + // Url = $"{server.Endpoint}/mcp" + // }; + // var provider = new RegistryServerProvider(testId, serverInfo); + + // // Act & Assert + // var exception = await Assert.ThrowsAsync( + // () => provider.CreateClientAsync(new McpClientOptions(), TestContext.Current.CancellationToken)); + + // Assert.Contains(((int)HttpStatusCode.NotFound).ToString(), exception.Message); + // } + [Fact] public async Task CreateClientAsync_WithStdioType_CreatesStdioClient() { @@ -106,15 +132,12 @@ public async Task CreateClientAsync_WithStdioType_CreatesStdioClient() }; var provider = new RegistryServerProvider(testId, serverInfo); - // Act & Assert - Should not throw, but the subprocess won't actually start correctly in test - // Without mocking, we can't easily verify the full client creation - // This test is just to verify the code path for stdio client creation - var exception = await Record.ExceptionAsync(() => provider.CreateClientAsync(new McpClientOptions())); + // Act & Assert - Should throw InvalidOperationException for subprocess startup failure + // since configuration is valid but external process fails to start properly + var exception = await Assert.ThrowsAsync( + () => provider.CreateClientAsync(new McpClientOptions(), TestContext.Current.CancellationToken)); - // We expect some kind of exception during the subprocess startup, but not an InvalidOperationException - // about missing command or invalid transport - Assert.NotNull(exception); - Assert.IsNotType(exception); + Assert.Contains($"Failed to create MCP client for registry server '{testId}'", exception.Message); } [Fact] @@ -135,17 +158,16 @@ public async Task CreateClientAsync_WithEnvVariables_MergesWithSystemEnvironment }; var provider = new RegistryServerProvider(testId, serverInfo); - // Act & Assert - Should not throw, but the subprocess won't actually start correctly in test - var exception = await Record.ExceptionAsync(() => provider.CreateClientAsync(new McpClientOptions())); + // Act & Assert - Should throw InvalidOperationException for subprocess startup failure + // since configuration is valid but external process fails to start properly + var exception = await Assert.ThrowsAsync( + () => provider.CreateClientAsync(new McpClientOptions(), TestContext.Current.CancellationToken)); - // We expect some kind of exception during the subprocess startup, but not an InvalidOperationException - // about missing command or invalid transport - Assert.NotNull(exception); - Assert.IsNotType(exception); + Assert.Contains($"Failed to create MCP client for registry server '{testId}'", exception.Message); } [Fact] - public async Task CreateClientAsync_NoUrlOrType_ThrowsInvalidOperationException() + public async Task CreateClientAsync_NoUrlOrType_ThrowsArgumentException() { // Arrange string testId = "invalidProvider"; @@ -157,10 +179,10 @@ public async Task CreateClientAsync_NoUrlOrType_ThrowsInvalidOperationException( var provider = new RegistryServerProvider(testId, serverInfo); // Act & Assert - var exception = await Assert.ThrowsAsync( - () => provider.CreateClientAsync(new McpClientOptions())); + var exception = await Assert.ThrowsAsync( + () => provider.CreateClientAsync(new McpClientOptions(), TestContext.Current.CancellationToken)); - Assert.Contains($"Registry server '{testId}' does not have a valid url or type for transport.", + Assert.Contains($"Registry server '{testId}' does not have a valid transport type.", exception.Message); } @@ -179,11 +201,38 @@ public async Task CreateClientAsync_StdioWithoutCommand_ThrowsInvalidOperationEx // Act & Assert var exception = await Assert.ThrowsAsync( - () => provider.CreateClientAsync(new McpClientOptions())); + () => provider.CreateClientAsync(new McpClientOptions(), TestContext.Current.CancellationToken)); Assert.Contains($"Registry server '{testId}' does not have a valid command for stdio transport.", exception.Message); } + + [Fact] + public async Task CreateClientAsync_WithInstallInstructions_IncludesInstructionsInException() + { + // Arrange + string testId = "toolWithInstructions"; + string installInstructions = "To install this tool, run: npm install -g my-mcp-tool"; + var serverInfo = new RegistryServerInfo + { + Description = "Tool that requires installation", + Type = "stdio", + Command = "my-mcp-tool", // This will fail since the command doesn't exist + Args = ["--serve"], + InstallInstructions = installInstructions + }; + var provider = new RegistryServerProvider(testId, serverInfo); + + // Act & Assert - Should throw InvalidOperationException with install instructions + var exception = await Assert.ThrowsAsync( + () => provider.CreateClientAsync(new McpClientOptions(), TestContext.Current.CancellationToken)); + + // Verify the exception message contains the install instructions + Assert.Contains($"Failed to initialize the '{testId}' MCP tool.", exception.Message); + Assert.Contains("This tool may require dependencies that are not installed.", exception.Message); + Assert.Contains("Installation Instructions:", exception.Message); + Assert.Contains(installInstructions, exception.Message); + } } internal sealed class MockHttpTestServer : IDisposable diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Runtime/McpRuntimeTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Runtime/McpRuntimeTests.cs index b56c3f7a72..e3154840e1 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Runtime/McpRuntimeTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Runtime/McpRuntimeTests.cs @@ -35,9 +35,9 @@ private static IOptions CreateOptions(ServiceStartOptions? return Microsoft.Extensions.Options.Options.Create(options ?? new ServiceStartOptions()); } - private static IMcpServer CreateMockServer() + private static McpServer CreateMockServer() { - return Substitute.For(); + return Substitute.For(); } private static ITelemetryService CreateMockTelemetryService() @@ -47,7 +47,7 @@ private static ITelemetryService CreateMockTelemetryService() private static RequestContext CreateListToolsRequest() { - return new RequestContext(CreateMockServer()) + return new RequestContext(CreateMockServer(), new() { Method = RequestMethods.ToolsList }) { Params = new ListToolsRequestParams() }; @@ -55,7 +55,7 @@ private static RequestContext CreateListToolsRequest() private static RequestContext CreateCallToolRequest(string toolName = "test-tool", IReadOnlyDictionary? arguments = null) { - return new RequestContext(CreateMockServer()) + return new RequestContext(CreateMockServer(), new() { Method = RequestMethods.ToolsCall }) { Params = new CallToolRequestParams { @@ -65,12 +65,12 @@ private static RequestContext CreateCallToolRequest(strin }; } - private static string GetAndAssertTagKeyValue(Activity activity, string tagName) + private static object GetAndAssertTagKeyValue(Activity activity, string tagName) { - var matching = activity.Tags.SingleOrDefault(x => string.Equals(x.Key, tagName, StringComparison.OrdinalIgnoreCase)); + var matching = activity.TagObjects.SingleOrDefault(x => string.Equals(x.Key, tagName, StringComparison.OrdinalIgnoreCase)); + Assert.False(matching.Equals(default(KeyValuePair)), $"Tag '{tagName}' was not found in activity tags."); Assert.NotNull(matching.Value); - Assert.NotEmpty(matching.Value); return matching.Value; } @@ -104,8 +104,7 @@ public void Constructor_WithNullToolLoader_ThrowsArgumentNullException() var options = CreateOptions(); // Act & Assert - var exception = Assert.Throws(() => - new McpRuntime(null!, options, mockTelemetry, logger)); + var exception = Assert.Throws(() => new McpRuntime(null!, options, mockTelemetry, logger)); Assert.Equal("toolLoader", exception.ParamName); } @@ -119,8 +118,7 @@ public void Constructor_WithNullOptions_ThrowsArgumentNullException() var mockTelemetry = CreateMockTelemetryService(); // Act & Assert - var exception = Assert.Throws(() => - new McpRuntime(mockToolLoader, null!, mockTelemetry, logger)); + var exception = Assert.Throws(() => new McpRuntime(mockToolLoader, null!, mockTelemetry, logger)); Assert.Equal("options", exception.ParamName); } @@ -134,8 +132,7 @@ public void Constructor_WithNullTelemetry_ThrowsArgumentNullException() var options = CreateOptions(); // Act & Assert - var exception = Assert.Throws(() => - new McpRuntime(mockToolLoader, options, null!, logger)); + var exception = Assert.Throws(() => new McpRuntime(mockToolLoader, options, null!, logger)); Assert.Equal("telemetry", exception.ParamName); } @@ -148,8 +145,7 @@ public void Constructor_WithNullLogger_ThrowsArgumentNullException() var options = CreateOptions(); // Act & Assert - var exception = Assert.Throws(() => - new McpRuntime(mockToolLoader, options, mockTelemetry, null!)); + var exception = Assert.Throws(() => new McpRuntime(mockToolLoader, options, mockTelemetry, null!)); Assert.Equal("logger", exception.ParamName); } @@ -187,31 +183,31 @@ public async Task ListToolsHandler_DelegatesToToolLoader() var mockTelemetry = CreateMockTelemetryService(); var activity = new Activity("test-activity"); mockTelemetry.StartActivity(Arg.Any(), Arg.Any()) - .Returns(ValueTask.FromResult(activity)); + .Returns(activity); var options = CreateOptions(); var runtime = new McpRuntime(mockToolLoader, options, mockTelemetry, logger); var expectedResult = new ListToolsResult { - Tools = new List - { + Tools = + [ new Tool { Name = "test-tool", Description = "A test tool" } - } + ] }; var request = CreateListToolsRequest(); mockToolLoader.ListToolsHandler(request, Arg.Any()) - .Returns(new ValueTask(expectedResult)); + .Returns(expectedResult); // Act - var result = await runtime.ListToolsHandler(request, CancellationToken.None); + var result = await runtime.ListToolsHandler(request, TestContext.Current.CancellationToken); // Assert Assert.Equal(expectedResult, result); await mockToolLoader.Received(1).ListToolsHandler(request, Arg.Any()); - await mockTelemetry.Received(1).StartActivity(ActivityName.ListToolsHandler, Arg.Any()); + mockTelemetry.Received(1).StartActivity(ActivityName.ListToolsHandler, Arg.Any()); Assert.Equal(ActivityStatusCode.Ok, activity.Status); } @@ -226,17 +222,17 @@ public async Task CallToolHandler_DelegatesToToolLoader() var mockTelemetry = CreateMockTelemetryService(); var activity = new Activity("test-activity"); mockTelemetry.StartActivity(Arg.Any(), Arg.Any()) - .Returns(ValueTask.FromResult(activity)); + .Returns(activity); var options = CreateOptions(); var runtime = new McpRuntime(mockToolLoader, options, mockTelemetry, logger); var expectedResult = new CallToolResult { - Content = new List - { + Content = + [ new TextContentBlock { Text = "Tool executed successfully" } - } + ] }; var toolName = "test-tool"; @@ -246,18 +242,21 @@ public async Task CallToolHandler_DelegatesToToolLoader() { OptionDefinitions.Common.SubscriptionName, JsonDocument.Parse("\"test-subscription\"").RootElement }, }); mockToolLoader.CallToolHandler(request, Arg.Any()) - .Returns(new ValueTask(expectedResult)); + .Returns(expectedResult); // Act - var result = await runtime.CallToolHandler(request, CancellationToken.None); + var result = await runtime.CallToolHandler(request, TestContext.Current.CancellationToken); // Assert Assert.Equal(expectedResult, result); await mockToolLoader.Received(1).CallToolHandler(request, Arg.Any()); - await mockTelemetry.Received(1).StartActivity(ActivityName.ToolExecuted, Arg.Any()); + mockTelemetry.Received(1).StartActivity(ActivityName.ToolExecuted, Arg.Any()); Assert.Equal(ActivityStatusCode.Ok, activity.Status); + var actualToolName = GetAndAssertTagKeyValue(activity, TagName.ToolName); + Assert.Equal(toolName, actualToolName); + // The runtime may or may not surface telemetry tags on the Activity depending on the // telemetry implementation. Assert the request and response contents instead. Assert.NotNull(request.Params); @@ -278,12 +277,12 @@ public async Task ListToolsHandler_WithCancellationToken_PassesTokenToToolLoader var options = CreateOptions(); var runtime = new McpRuntime(mockToolLoader, options, CreateMockTelemetryService(), logger); - var expectedResult = new ListToolsResult { Tools = new List() }; + var expectedResult = new ListToolsResult { Tools = [] }; var request = CreateListToolsRequest(); var cancellationToken = new CancellationToken(); mockToolLoader.ListToolsHandler(request, cancellationToken) - .Returns(new ValueTask(expectedResult)); + .Returns(expectedResult); // Act var result = await runtime.ListToolsHandler(request, cancellationToken); @@ -303,12 +302,12 @@ public async Task CallToolHandler_WithCancellationToken_PassesTokenToToolLoader( var options = CreateOptions(); var runtime = new McpRuntime(mockToolLoader, options, CreateMockTelemetryService(), logger); - var expectedResult = new CallToolResult { Content = new List() }; + var expectedResult = new CallToolResult { Content = [] }; var request = CreateCallToolRequest(); var cancellationToken = new CancellationToken(); mockToolLoader.CallToolHandler(request, cancellationToken) - .Returns(new ValueTask(expectedResult)); + .Returns(expectedResult); // Act var result = await runtime.CallToolHandler(request, cancellationToken); @@ -329,7 +328,7 @@ public async Task ListToolsHandler_WhenToolLoaderThrows_PropagatesException() var mockTelemetry = CreateMockTelemetryService(); var activity = new Activity("test-activity"); mockTelemetry.StartActivity(Arg.Any(), Arg.Any()) - .Returns(ValueTask.FromResult(activity)); + .Returns(activity); var options = CreateOptions(); var runtime = new McpRuntime(mockToolLoader, options, mockTelemetry, logger); @@ -342,11 +341,11 @@ public async Task ListToolsHandler_WhenToolLoaderThrows_PropagatesException() // Act & Assert var actualException = await Assert.ThrowsAsync(() => - runtime.ListToolsHandler(request, CancellationToken.None).AsTask()); + runtime.ListToolsHandler(request, TestContext.Current.CancellationToken).AsTask()); Assert.Equal(expectedException.Message, actualException.Message); - await mockTelemetry.Received(1).StartActivity(ActivityName.ListToolsHandler, Arg.Any()); + mockTelemetry.Received(1).StartActivity(ActivityName.ListToolsHandler, Arg.Any()); Assert.Equal(ActivityStatusCode.Error, activity.Status); GetAndAssertTagKeyValue(activity, TagName.ErrorDetails); @@ -363,13 +362,14 @@ public async Task CallToolHandler_WhenToolLoaderThrows_PropagatesException() var mockTelemetry = CreateMockTelemetryService(); var activity = new Activity("test-activity"); mockTelemetry.StartActivity(Arg.Any(), Arg.Any()) - .Returns(ValueTask.FromResult(activity)); + .Returns(activity); var options = CreateOptions(); var runtime = new McpRuntime(mockToolLoader, options, mockTelemetry, logger); - var request = CreateCallToolRequest(); - var expectedException = new InvalidOperationException("Tool loader failed"); + var toolName = "test-tool"; + var request = CreateCallToolRequest(toolName); + var expectedException = new Exception("Tool loader failed"); mockToolLoader.CallToolHandler(request, Arg.Any()) .Returns>(x => throw expectedException); @@ -377,19 +377,19 @@ public async Task CallToolHandler_WhenToolLoaderThrows_PropagatesException() // Act & Assert Assert.NotNull(request.Params); - var actualException = await Assert.ThrowsAsync(() => - runtime.CallToolHandler(request, CancellationToken.None).AsTask()); + var actualException = await Assert.ThrowsAsync(() => + runtime.CallToolHandler(request, TestContext.Current.CancellationToken).AsTask()); Assert.Equal(expectedException.Message, actualException.Message); - await mockTelemetry.Received(1).StartActivity(ActivityName.ToolExecuted, Arg.Any()); + mockTelemetry.Received(1).StartActivity(ActivityName.ToolExecuted, Arg.Any()); Assert.Equal(ActivityStatusCode.Error, activity.Status); var actualToolName = GetAndAssertTagKeyValue(activity, TagName.ToolName); - Assert.Equal(request.Params.Name, actualToolName); + Assert.Equal(toolName, actualToolName); GetAndAssertTagKeyValue(activity, TagName.ErrorDetails); - Assert.DoesNotContain(activity.Tags, + Assert.DoesNotContain(activity.TagObjects, x => string.Equals(x.Key, TagName.SubscriptionGuid, StringComparison.OrdinalIgnoreCase)); } @@ -433,27 +433,27 @@ public async Task Runtime_ImplementsIMcpRuntimeInterface() var logger = serviceProvider.GetRequiredService>(); var mockToolLoader = Substitute.For(); var options = CreateOptions(); - IMcpRuntime runtime = new McpRuntime(mockToolLoader, options, CreateMockTelemetryService(), logger); + var runtime = new McpRuntime(mockToolLoader, options, CreateMockTelemetryService(), logger); // Setup mock responses - var listToolsResult = new ListToolsResult { Tools = new List() }; - var callToolResult = new CallToolResult { Content = new List() }; + var listToolsResult = new ListToolsResult { Tools = [] }; + var callToolResult = new CallToolResult { Content = [] }; mockToolLoader.ListToolsHandler(Arg.Any>(), Arg.Any()) - .Returns(new ValueTask(listToolsResult)); + .Returns(listToolsResult); mockToolLoader.CallToolHandler(Arg.Any>(), Arg.Any()) - .Returns(new ValueTask(callToolResult)); + .Returns(callToolResult); // Act & Assert - Interface methods should be available - var listResult = await runtime.ListToolsHandler(CreateListToolsRequest(), CancellationToken.None); - var callResult = await runtime.CallToolHandler(CreateCallToolRequest(), CancellationToken.None); + var listResult = await runtime.ListToolsHandler(CreateListToolsRequest(), TestContext.Current.CancellationToken); + var callResult = await runtime.CallToolHandler(CreateCallToolRequest(), TestContext.Current.CancellationToken); Assert.Equal(listToolsResult, listResult); Assert.Equal(callToolResult, callResult); } [Fact] - public async Task ListToolsHandler_WithNullRequest_DelegatesToToolLoader() + public async Task ListToolsHandler_WithNullParameters_DelegatesToToolLoader() { // Arrange var serviceProvider = CreateServiceProvider(); @@ -461,21 +461,22 @@ public async Task ListToolsHandler_WithNullRequest_DelegatesToToolLoader() var mockToolLoader = Substitute.For(); var options = CreateOptions(); var runtime = new McpRuntime(mockToolLoader, options, CreateMockTelemetryService(), logger); + var request = new RequestContext(CreateMockServer(), new() { Method = RequestMethods.ToolsList }); - var expectedResult = new ListToolsResult { Tools = new List() }; - mockToolLoader.ListToolsHandler(null!, Arg.Any()) - .Returns(new ValueTask(expectedResult)); + var expectedResult = new ListToolsResult { Tools = [] }; + mockToolLoader.ListToolsHandler(request, Arg.Any()) + .Returns(expectedResult); // Act - var result = await runtime.ListToolsHandler(null!, CancellationToken.None); + var result = await runtime.ListToolsHandler(request, TestContext.Current.CancellationToken); // Assert Assert.Equal(expectedResult, result); - await mockToolLoader.Received(1).ListToolsHandler(null!, Arg.Any()); + await mockToolLoader.Received(1).ListToolsHandler(request, Arg.Any()); } [Fact] - public async Task CallToolHandler_WithNullRequest_ReturnsError() + public async Task CallToolHandler_WithNullParameters_ReturnsError() { // Arrange var serviceProvider = CreateServiceProvider(); @@ -486,12 +487,13 @@ public async Task CallToolHandler_WithNullRequest_ReturnsError() var mockTelemetry = CreateMockTelemetryService(); var activity = new Activity("test-activity"); mockTelemetry.StartActivity(Arg.Any(), Arg.Any()) - .Returns(ValueTask.FromResult(activity)); + .Returns(activity); var runtime = new McpRuntime(mockToolLoader, options, mockTelemetry, logger); + var request = new RequestContext(CreateMockServer(), new() { Method = RequestMethods.ToolsCall }); // Act - var result = await runtime.CallToolHandler(null!, CancellationToken.None); + var result = await runtime.CallToolHandler(request, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -506,7 +508,7 @@ public async Task CallToolHandler_WithNullRequest_ReturnsError() // Verify that the tool loader was NOT called since the null request is handled at the runtime level await mockToolLoader.DidNotReceive().CallToolHandler(Arg.Any>(), Arg.Any()); - await mockTelemetry.Received(1).StartActivity(ActivityName.ToolExecuted, Arg.Any()); + mockTelemetry.Received(1).StartActivity(ActivityName.ToolExecuted, Arg.Any()); Assert.Equal(ActivityStatusCode.Error, activity.Status); GetAndAssertTagKeyValue(activity, TagName.ErrorDetails); } @@ -538,12 +540,12 @@ public async Task CallToolHandler_WithSpecificCancellationToken_PassesCorrectTok var options = CreateOptions(); var runtime = new McpRuntime(mockToolLoader, options, CreateMockTelemetryService(), logger); - var expectedResult = new CallToolResult { Content = new List() }; + var expectedResult = new CallToolResult { Content = [] }; var request = CreateCallToolRequest(); var specificToken = new CancellationTokenSource().Token; mockToolLoader.CallToolHandler(request, specificToken) - .Returns(new ValueTask(expectedResult)); + .Returns(expectedResult); // Act var result = await runtime.CallToolHandler(request, specificToken); @@ -563,12 +565,12 @@ public async Task ListToolsHandler_WithSpecificCancellationToken_PassesCorrectTo var options = CreateOptions(); var runtime = new McpRuntime(mockToolLoader, options, CreateMockTelemetryService(), logger); - var expectedResult = new ListToolsResult { Tools = new List() }; + var expectedResult = new ListToolsResult { Tools = [] }; var request = CreateListToolsRequest(); var specificToken = new CancellationTokenSource().Token; mockToolLoader.ListToolsHandler(request, specificToken) - .Returns(new ValueTask(expectedResult)); + .Returns(expectedResult); // Act var result = await runtime.ListToolsHandler(request, specificToken); @@ -609,10 +611,10 @@ public async Task CallToolHandler_CanSucceedBeforeListingTools() var expectedResult = new CallToolResult { - Content = new List - { + Content = + [ new TextContentBlock { Text = "Tool executed successfully without prior listing" } - } + ] }; var request = CreateCallToolRequest("existing-tool", new Dictionary @@ -620,10 +622,10 @@ public async Task CallToolHandler_CanSucceedBeforeListingTools() { "action", JsonDocument.Parse("\"execute\"").RootElement } }); mockToolLoader.CallToolHandler(request, Arg.Any()) - .Returns(new ValueTask(expectedResult)); + .Returns(expectedResult); // Act - Call tool directly without listing tools first - var result = await runtime.CallToolHandler(request, CancellationToken.None); + var result = await runtime.CallToolHandler(request, TestContext.Current.CancellationToken); // Assert Assert.Equal(expectedResult, result); @@ -660,10 +662,10 @@ public async Task CallToolHandler_SetsActivityTags() var expectedResult = new CallToolResult { - Content = new List - { + Content = + [ new TextContentBlock { Text = "Tool executed successfully without prior listing" } - } + ] }; var request = CreateCallToolRequest(toolName, new Dictionary @@ -672,10 +674,10 @@ public async Task CallToolHandler_SetsActivityTags() { OptionDefinitions.Common.SubscriptionName, JsonDocument.Parse($"\"{testSubscriptionId}\"").RootElement } }); mockToolLoader.CallToolHandler(request, Arg.Any()) - .Returns(new ValueTask(expectedResult)); + .Returns(expectedResult); // Act - Call tool directly without listing tools first - var result = await runtime.CallToolHandler(request, CancellationToken.None); + var result = await runtime.CallToolHandler(request, TestContext.Current.CancellationToken); // Assert Assert.Equal(expectedResult, result); @@ -780,7 +782,7 @@ public async Task CallToolHandler_WithToolLoaderError_ShouldReturnErrorAndSetTel var mockTelemetry = CreateMockTelemetryService(); var activity = new Activity("test-activity"); mockTelemetry.StartActivity(Arg.Any(), Arg.Any()) - .Returns(ValueTask.FromResult(activity)); + .Returns(activity); var options = CreateOptions(); var runtime = new McpRuntime(mockToolLoader, options, mockTelemetry, logger); @@ -788,10 +790,10 @@ public async Task CallToolHandler_WithToolLoaderError_ShouldReturnErrorAndSetTel var errorText = "Some error details"; var expectedResult = new CallToolResult { - Content = new List - { + Content = + [ new TextContentBlock { Text = errorText } - }, + ], IsError = true }; @@ -802,12 +804,12 @@ public async Task CallToolHandler_WithToolLoaderError_ShouldReturnErrorAndSetTel { OptionDefinitions.Common.SubscriptionName, JsonDocument.Parse("\"test-subscription\"").RootElement }, }); mockToolLoader.CallToolHandler(request, Arg.Any()) - .Returns(new ValueTask(expectedResult)); + .Returns(expectedResult); // Act - var result = await runtime.CallToolHandler(request, CancellationToken.None); + var result = await runtime.CallToolHandler(request, TestContext.Current.CancellationToken); - await mockTelemetry.Received(1).StartActivity(ActivityName.ToolExecuted, Arg.Any()); + mockTelemetry.Received(1).StartActivity(ActivityName.ToolExecuted, Arg.Any()); Assert.Equal(ActivityStatusCode.Error, activity.Status); // Error details are present in the CallToolResult content; assert that instead of relying diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceCollectionExtensionsSerializedTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceCollectionExtensionsSerializedTests.cs new file mode 100644 index 0000000000..32e4e00688 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceCollectionExtensionsSerializedTests.cs @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Areas.Server.Commands; +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Configuration; +using Azure.Mcp.Core.Helpers; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands; + +// This is intentionally placed after the namespace declaration to avoid +// conflicts with Azure.Mcp.Core.Areas.Server.Options +using Options = Microsoft.Extensions.Options.Options; + +public class ServiceCollectionExtensionsSerializedTests +{ + private IServiceCollection SetupBaseServices() + { + var services = CommandFactoryHelpers.SetupCommonServices(); + services.AddSingleton(sp => CommandFactoryHelpers.CreateCommandFactory(sp)); + + return services; + } + + [Fact] + public void InitializeConfigurationAndOptions_Defaults() + { + // Assert + var expectedVersion = AssemblyHelper.GetAssemblyVersion(typeof(ServiceCollectionExtensionsTests).Assembly); + var services = SetupBaseServices(); + + // Act + ServiceCollectionExtensions.InitializeConfigurationAndOptions(services); + + // Assert + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>(); + + Assert.NotNull(options.Value); + + var actual = options.Value; + Assert.Equal("Azure.Mcp.Server", actual.Name); + Assert.Equal("Azure MCP Server", actual.DisplayName); + Assert.Equal("azmcp", actual.RootCommandGroupName); + Assert.Equal(expectedVersion, actual.Version); + + Assert.True(actual.IsTelemetryEnabled); + } + + /// + /// When is used, telemetry is disabled + /// even when AZURE_MCP_COLLECT_TELEMETRY is explicitly set to true. + /// + [Fact] + public void InitializeConfigurationAndOptions_HttpTransport() + { + // Assert + var serviceStartOptions = new ServiceStartOptions + { + Transport = TransportTypes.Http, + }; + var services = SetupBaseServices().AddSingleton(Options.Create(serviceStartOptions)); + + // Act + Environment.SetEnvironmentVariable("AZURE_MCP_COLLECT_TELEMETRY", "true"); + ServiceCollectionExtensions.InitializeConfigurationAndOptions(services); + var provider = services.BuildServiceProvider(); + + // Assert + var options = provider.GetRequiredService>(); + + Assert.NotNull(options.Value); + + var actual = options.Value; + Assert.Equal("Azure.Mcp.Server", actual.Name); + Assert.Equal("Azure MCP Server", actual.DisplayName); + Assert.Equal("azmcp", actual.RootCommandGroupName); + Assert.False(actual.IsTelemetryEnabled); + } + + [Fact] + public void InitializeConfigurationAndOptions_Stdio() + { + // Assert + var expectedVersion = AssemblyHelper.GetAssemblyVersion(typeof(ServiceCollectionExtensionsTests).Assembly); + var services = SetupBaseServices(); + + // Act + Environment.SetEnvironmentVariable("AZURE_MCP_COLLECT_TELEMETRY", "false"); + ServiceCollectionExtensions.InitializeConfigurationAndOptions(services); + var provider = services.BuildServiceProvider(); + + // Assert + var options = provider.GetRequiredService>(); + + Assert.NotNull(options.Value); + + var actual = options.Value; + Assert.Equal("Azure.Mcp.Server", actual.Name); + Assert.Equal("Azure MCP Server", actual.DisplayName); + Assert.Equal("azmcp", actual.RootCommandGroupName); + Assert.Equal(expectedVersion, actual.Version); + + Assert.False(actual.IsTelemetryEnabled); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceCollectionExtensionsTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceCollectionExtensionsTests.cs index 35f79da7a8..91ee813d9c 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceCollectionExtensionsTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceCollectionExtensionsTests.cs @@ -12,13 +12,12 @@ using ModelContextProtocol.Server; using Xunit; +using TransportTypes = Azure.Mcp.Core.Areas.Server.Options.TransportTypes; + namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands; public class ServiceCollectionExtensionsTests { - // TransportTypes is internal, so we'll use strings directly - private const string StdioTransport = "stdio"; - private IServiceCollection SetupBaseServices() { var services = CommandFactoryHelpers.SetupCommonServices(); @@ -34,7 +33,7 @@ public void AddAzureMcpServer_RegistersCommonServices() var services = SetupBaseServices(); var options = new ServiceStartOptions { - Transport = StdioTransport + Transport = TransportTypes.StdIo }; // Act @@ -64,7 +63,7 @@ public void AddAzureMcpServer_WithSingleProxy_RegistersSingleProxyToolLoader() var services = SetupBaseServices(); var options = new ServiceStartOptions { - Transport = StdioTransport, + Transport = TransportTypes.StdIo, Mode = "single" }; @@ -90,7 +89,7 @@ public void AddAzureMcpServer_WithNamespaceProxy_RegistersCompositeToolLoader() var services = SetupBaseServices(); var options = new ServiceStartOptions { - Transport = StdioTransport, + Transport = TransportTypes.StdIo, Mode = "namespace" }; @@ -101,13 +100,14 @@ public void AddAzureMcpServer_WithNamespaceProxy_RegistersCompositeToolLoader() var provider = services.BuildServiceProvider(); // Verify the correct tool loader is registered - // In namespace mode, we now use CompositeToolLoader that includes ServerToolLoader + // In namespace mode, we now use CompositeToolLoader that includes NamespaceToolLoader Assert.NotNull(provider.GetService()); Assert.IsType(provider.GetService()); // Verify discovery strategy is registered + // In namespace mode, we only use RegistryDiscoveryStrategy (for external MCP servers) Assert.NotNull(provider.GetService()); - Assert.IsType(provider.GetService()); + Assert.IsType(provider.GetService()); } [Fact] @@ -117,7 +117,7 @@ public void AddAzureMcpServer_WithDefaultMode_RegistersServerToolLoader() var services = SetupBaseServices(); var options = new ServiceStartOptions { - Transport = StdioTransport, + Transport = TransportTypes.StdIo, // No mode specified - should use default "namespace" mode }; @@ -139,7 +139,7 @@ public void AddAzureMcpServer_WithStdioTransport_ConfiguresStdioTransport() var services = SetupBaseServices(); var options = new ServiceStartOptions { - Transport = StdioTransport, + Transport = TransportTypes.StdIo, // Define proxy as "single" to prevent CompositeDiscoveryStrategy error Mode = "single" }; @@ -154,8 +154,8 @@ public void AddAzureMcpServer_WithStdioTransport_ConfiguresStdioTransport() // Check that appropriate registration was completed Assert.NotNull(provider.GetService()); - // Verify that the service collection contains an IMcpServer registration - Assert.Contains(services, sd => sd.ServiceType == typeof(IMcpServer)); + // Verify that the service collection contains an McpServer registration + Assert.Contains(services, sd => sd.ServiceType == typeof(McpServer)); } [Fact] @@ -165,7 +165,7 @@ public void AddAzureMcpServer_ConfiguresMcpServerOptions() var services = SetupBaseServices(); var options = new ServiceStartOptions { - Transport = StdioTransport + Transport = TransportTypes.StdIo, }; // Act @@ -180,7 +180,9 @@ public void AddAzureMcpServer_ConfiguresMcpServerOptions() Assert.Equal("2024-11-05", mcpServerOptions.ProtocolVersion); Assert.NotNull(mcpServerOptions.ServerInfo); Assert.NotNull(mcpServerOptions.Capabilities); - Assert.NotNull(mcpServerOptions.Capabilities.Tools); + Assert.NotNull(mcpServerOptions.Handlers); + Assert.NotNull(mcpServerOptions.Handlers.ListToolsHandler); + Assert.NotNull(mcpServerOptions.Handlers.CallToolHandler); } [Fact] @@ -190,7 +192,7 @@ public void AddAzureMcpServer_RegistersOptionsWithSameInstance() var services = SetupBaseServices(); var options = new ServiceStartOptions { - Transport = StdioTransport, + Transport = TransportTypes.StdIo, ReadOnly = true }; @@ -217,7 +219,7 @@ public void AddAzureMcpServer_WithReadOnlyOption_RegistersOption() var services = SetupBaseServices(); var options = new ServiceStartOptions { - Transport = StdioTransport, + Transport = TransportTypes.StdIo, ReadOnly = true }; @@ -244,7 +246,7 @@ public void AddAzureMcpServer_WithSpecificServiceAreas_RegistersCompositeToolLoa var services = SetupBaseServices(); var options = new ServiceStartOptions { - Transport = StdioTransport, + Transport = TransportTypes.StdIo, Namespace = ["keyvault", "storage"] }; @@ -276,7 +278,7 @@ public void AddAzureMcpServer_WithSingleServiceArea_RegistersAppropriateServices var services = SetupBaseServices(); var options = new ServiceStartOptions { - Transport = StdioTransport, + Transport = TransportTypes.StdIo, Namespace = [serviceArea] }; @@ -302,7 +304,7 @@ public void AddAzureMcpServer_WithInvalidProxyMode_UsesDefaultLoader() var services = SetupBaseServices(); var options = new ServiceStartOptions { - Transport = StdioTransport, + Transport = TransportTypes.StdIo, Mode = "invalid-mode" }; @@ -323,7 +325,7 @@ public void AddAzureMcpServer_WithNullProxy_UsesDefaultLoader() var services = SetupBaseServices(); var options = new ServiceStartOptions { - Transport = StdioTransport, + Transport = TransportTypes.StdIo, Mode = null }; @@ -344,7 +346,7 @@ public void AddAzureMcpServer_WithAllMode_RegistersCompositeToolLoader() var services = SetupBaseServices(); var options = new ServiceStartOptions { - Transport = StdioTransport, + Transport = TransportTypes.StdIo, Mode = "all" }; @@ -370,7 +372,7 @@ public void AddAzureMcpServer_ConfiguresServerInstructions() var services = SetupBaseServices(); var options = new ServiceStartOptions { - Transport = StdioTransport + Transport = TransportTypes.StdIo }; // Act diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceInfoCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceInfoCommandTests.cs new file mode 100644 index 0000000000..149f62dae6 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceInfoCommandTests.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Text.Json; +using Azure.Mcp.Core.Areas.Server.Commands; +using Azure.Mcp.Core.Configuration; +using Azure.Mcp.Core.Models.Command; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands; + +public class ServiceInfoCommandTests +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly AzureMcpServerConfiguration _mcpServerConfiguration; + private readonly CommandContext _context; + private readonly ServiceInfoCommand _command; + private readonly Command _commandDefinition; + + public ServiceInfoCommandTests() + { + var collection = new ServiceCollection(); + _serviceProvider = collection.BuildServiceProvider(); + + _context = new(_serviceProvider); + _logger = Substitute.For>(); + _mcpServerConfiguration = new AzureMcpServerConfiguration + { + Name = "Test-Name?", + Version = "Test-Version?", + DisplayName = "Test Display", + RootCommandGroupName = "azmcp" + }; + _command = new(Microsoft.Extensions.Options.Options.Create(_mcpServerConfiguration), _logger); + _commandDefinition = _command.GetCommand(); + } + + [Fact] + public async Task ExecuteAsync_ReturnsCorrectProperties() + { + var args = _commandDefinition.Parse([]); + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + + var json = JsonSerializer.Serialize(response.Results); + var result = JsonSerializer.Deserialize(json, ServiceInfoJsonContext.Default.ServiceInfoCommandResult); + + Assert.NotNull(result); + Assert.Equal(_mcpServerConfiguration.Name, result.Name); + Assert.Equal(_mcpServerConfiguration.Version, result.Version); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/BaseToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/BaseToolLoaderTests.cs index 93b0c2946b..7f35d37443 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/BaseToolLoaderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/BaseToolLoaderTests.cs @@ -20,7 +20,7 @@ public void CreateClientOptions_WithNoCapabilities_ReturnsOptionsWithNoCapabilit { // Arrange var loader = new TestableBaseToolLoader(NullLogger.Instance); - var mockServer = Substitute.For(); + var mockServer = Substitute.For(); mockServer.ClientCapabilities.Returns((ClientCapabilities?)null); // Act @@ -28,9 +28,9 @@ public void CreateClientOptions_WithNoCapabilities_ReturnsOptionsWithNoCapabilit // Assert Assert.NotNull(options); - Assert.NotNull(options.Capabilities); - Assert.Null(options.Capabilities.Sampling); - Assert.Null(options.Capabilities.Elicitation); + Assert.NotNull(options.Handlers); + Assert.Null(options.Handlers.SamplingHandler); + Assert.Null(options.Handlers.ElicitationHandler); } [Fact] @@ -38,7 +38,7 @@ public void CreateClientOptions_WithEmptyCapabilities_ReturnsOptionsWithNoCapabi { // Arrange var loader = new TestableBaseToolLoader(NullLogger.Instance); - var mockServer = Substitute.For(); + var mockServer = Substitute.For(); mockServer.ClientCapabilities.Returns(new ClientCapabilities()); // Act @@ -46,9 +46,9 @@ public void CreateClientOptions_WithEmptyCapabilities_ReturnsOptionsWithNoCapabi // Assert Assert.NotNull(options); - Assert.NotNull(options.Capabilities); - Assert.Null(options.Capabilities.Sampling); - Assert.Null(options.Capabilities.Elicitation); + Assert.NotNull(options.Handlers); + Assert.Null(options.Handlers.SamplingHandler); + Assert.Null(options.Handlers.ElicitationHandler); } [Fact] @@ -56,7 +56,7 @@ public void CreateClientOptions_WithSamplingCapability_ReturnsOptionsWithSamplin { // Arrange var loader = new TestableBaseToolLoader(NullLogger.Instance); - var mockServer = Substitute.For(); + var mockServer = Substitute.For(); var capabilities = new ClientCapabilities { Sampling = new SamplingCapability() @@ -68,9 +68,9 @@ public void CreateClientOptions_WithSamplingCapability_ReturnsOptionsWithSamplin // Assert Assert.NotNull(options); - Assert.NotNull(options.Capabilities); - Assert.NotNull(options.Capabilities.Sampling); - Assert.Null(options.Capabilities.Elicitation); + Assert.NotNull(options.Handlers); + Assert.NotNull(options.Handlers.SamplingHandler); + Assert.Null(options.Handlers.ElicitationHandler); } [Fact] @@ -78,7 +78,7 @@ public void CreateClientOptions_WithElicitationCapability_ReturnsOptionsWithElic { // Arrange var loader = new TestableBaseToolLoader(NullLogger.Instance); - var mockServer = Substitute.For(); + var mockServer = Substitute.For(); var capabilities = new ClientCapabilities { Elicitation = new ElicitationCapability() @@ -90,9 +90,9 @@ public void CreateClientOptions_WithElicitationCapability_ReturnsOptionsWithElic // Assert Assert.NotNull(options); - Assert.NotNull(options.Capabilities); - Assert.Null(options.Capabilities.Sampling); - Assert.NotNull(options.Capabilities.Elicitation); + Assert.NotNull(options.Handlers); + Assert.Null(options.Handlers.SamplingHandler); + Assert.NotNull(options.Handlers.ElicitationHandler); } [Fact] @@ -100,7 +100,7 @@ public void CreateClientOptions_WithBothCapabilities_ReturnsOptionsWithBothCapab { // Arrange var loader = new TestableBaseToolLoader(NullLogger.Instance); - var mockServer = Substitute.For(); + var mockServer = Substitute.For(); var capabilities = new ClientCapabilities { Sampling = new SamplingCapability(), @@ -113,9 +113,9 @@ public void CreateClientOptions_WithBothCapabilities_ReturnsOptionsWithBothCapab // Assert Assert.NotNull(options); - Assert.NotNull(options.Capabilities); - Assert.NotNull(options.Capabilities.Sampling); - Assert.NotNull(options.Capabilities.Elicitation); + Assert.NotNull(options.Handlers); + Assert.NotNull(options.Handlers.SamplingHandler); + Assert.NotNull(options.Handlers.ElicitationHandler); } [Fact] @@ -123,7 +123,7 @@ public void CreateClientOptions_WithServerClientInfo_CopiesClientInfoToOptions() { // Arrange var loader = new TestableBaseToolLoader(NullLogger.Instance); - var mockServer = Substitute.For(); + var mockServer = Substitute.For(); var clientInfo = new Implementation { Name = "test-client", @@ -145,7 +145,7 @@ public void CreateClientOptions_WithNullServerClientInfo_HandlesGracefully() { // Arrange var loader = new TestableBaseToolLoader(NullLogger.Instance); - var mockServer = Substitute.For(); + var mockServer = Substitute.For(); mockServer.ClientInfo.Returns((Implementation?)null); mockServer.ClientCapabilities.Returns(new ClientCapabilities()); @@ -162,7 +162,7 @@ public async Task CreateClientOptions_SamplingHandler_ValidatesRequestAndThrowsO { // Arrange var loader = new TestableBaseToolLoader(NullLogger.Instance); - var mockServer = Substitute.For(); + var mockServer = Substitute.For(); var capabilities = new ClientCapabilities { Sampling = new SamplingCapability() @@ -171,11 +171,11 @@ public async Task CreateClientOptions_SamplingHandler_ValidatesRequestAndThrowsO // Act var options = loader.CreateClientOptionsPublic(mockServer); - Assert.NotNull(options.Capabilities?.Sampling?.SamplingHandler); + Assert.NotNull(options.Handlers.SamplingHandler); // Assert - verify handler validates null request await Assert.ThrowsAsync(async () => - await options.Capabilities.Sampling.SamplingHandler(null!, default!, CancellationToken.None)); + await options.Handlers.SamplingHandler(null!, default!, TestContext.Current.CancellationToken)); } [Fact] @@ -183,7 +183,7 @@ public async Task CreateClientOptions_SamplingHandler_DelegatesToServerSendReque { // Arrange var loader = new TestableBaseToolLoader(NullLogger.Instance); - var mockServer = Substitute.For(); + var mockServer = Substitute.For(); var capabilities = new ClientCapabilities { Sampling = new SamplingCapability() @@ -218,9 +218,9 @@ public async Task CreateClientOptions_SamplingHandler_DelegatesToServerSendReque // Act var options = loader.CreateClientOptionsPublic(mockServer); - Assert.NotNull(options.Capabilities?.Sampling?.SamplingHandler); + Assert.NotNull(options.Handlers.SamplingHandler); - await options.Capabilities.Sampling.SamplingHandler(samplingRequest, default!, CancellationToken.None); + await options.Handlers.SamplingHandler(samplingRequest, default!, TestContext.Current.CancellationToken); // Assert - verify SendRequestAsync was called with sampling method await mockServer.Received(1).SendRequestAsync( @@ -233,7 +233,7 @@ public async Task CreateClientOptions_ElicitationHandler_DelegatesToServerSendRe { // Arrange var loader = new TestableBaseToolLoader(NullLogger.Instance); - var mockServer = Substitute.For(); + var mockServer = Substitute.For(); var capabilities = new ClientCapabilities { Elicitation = new ElicitationCapability() @@ -256,9 +256,9 @@ public async Task CreateClientOptions_ElicitationHandler_DelegatesToServerSendRe // Act var options = loader.CreateClientOptionsPublic(mockServer); - Assert.NotNull(options.Capabilities?.Elicitation?.ElicitationHandler); + Assert.NotNull(options.Handlers.ElicitationHandler); - await options.Capabilities.Elicitation.ElicitationHandler(elicitationRequest, CancellationToken.None); + await options.Handlers.ElicitationHandler(elicitationRequest, TestContext.Current.CancellationToken); // Assert - verify SendRequestAsync was called with elicitation method await mockServer.Received(1).SendRequestAsync( @@ -271,7 +271,7 @@ public async Task CreateClientOptions_ElicitationHandler_ValidatesRequestAndThro { // Arrange var loader = new TestableBaseToolLoader(NullLogger.Instance); - var mockServer = Substitute.For(); + var mockServer = Substitute.For(); var capabilities = new ClientCapabilities { Elicitation = new ElicitationCapability() @@ -280,11 +280,11 @@ public async Task CreateClientOptions_ElicitationHandler_ValidatesRequestAndThro // Act var options = loader.CreateClientOptionsPublic(mockServer); - Assert.NotNull(options.Capabilities?.Elicitation?.ElicitationHandler); + Assert.NotNull(options.Handlers.ElicitationHandler); // Assert - verify handler validates null request await Assert.ThrowsAsync(async () => - await options.Capabilities.Elicitation.ElicitationHandler(null!, CancellationToken.None)); + await options.Handlers.ElicitationHandler.Invoke(null!, TestContext.Current.CancellationToken)); } internal sealed class TestableBaseToolLoader : BaseToolLoader @@ -294,7 +294,7 @@ public TestableBaseToolLoader(ILogger logger) { } - public McpClientOptions CreateClientOptionsPublic(IMcpServer server) + public McpClientOptions CreateClientOptionsPublic(McpServer server) { return CreateClientOptions(server); } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs index 71cf9a516a..69e906f368 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs @@ -31,8 +31,8 @@ private static (CommandFactoryToolLoader toolLoader, CommandFactory commandFacto private static ModelContextProtocol.Server.RequestContext CreateRequest() { - var mockServer = Substitute.For(); - return new ModelContextProtocol.Server.RequestContext(mockServer) + var mockServer = Substitute.For(); + return new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsList }) { Params = new ListToolsRequestParams() }; @@ -44,7 +44,7 @@ public async Task ListToolsHandler_ReturnsToolsWithExpectedProperties() var (toolLoader, commandFactory) = CreateToolLoader(); var request = CreateRequest(); - var result = await toolLoader.ListToolsHandler(request, CancellationToken.None); + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); // Verify basic structure Assert.NotNull(result); @@ -84,7 +84,7 @@ public async Task ListToolsHandler_WithReadOnlyOption_ReturnsOnlyReadOnlyTools() var (toolLoader, _) = CreateToolLoader(readOnlyOptions); var request = CreateRequest(); - var result = await toolLoader.ListToolsHandler(request, CancellationToken.None); + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); // Verify basic structure Assert.NotNull(result); @@ -99,6 +99,80 @@ public async Task ListToolsHandler_WithReadOnlyOption_ReturnsOnlyReadOnlyTools() } } + [Fact] + public async Task ListToolsHandler_WithToolFilter_ReturnsOnlySpecifiedTool() + { + // Arrange + var (_, commandFactory) = CreateToolLoader(); + var availableCommands = CommandFactory.GetVisibleCommands(commandFactory.AllCommands).ToList(); + + // Skip test if no commands are available + if (!availableCommands.Any()) + { + return; + } + + var specificToolName = availableCommands.First().Key; + var toolOptions = new ToolLoaderOptions { Tool = [specificToolName] }; + var (toolLoader, _) = CreateToolLoader(toolOptions); + var request = CreateRequest(); + + // Act + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Tools); + Assert.Single(result.Tools); + Assert.Equal(specificToolName, result.Tools[0].Name); + } + + [Fact] + public async Task ListToolsHandler_WithNonExistentToolFilter_ReturnsEmptyList() + { + // Arrange + var nonExistentTool = "non-existent-tool-name"; + var toolOptions = new ToolLoaderOptions { Tool = [nonExistentTool] }; + var (toolLoader, _) = CreateToolLoader(toolOptions); + var request = CreateRequest(); + + // Act + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Tools); + Assert.Empty(result.Tools); + } + + [Fact] + public async Task ListToolsHandler_WithToolFilterCaseInsensitive_ReturnsSpecifiedTool() + { + // Arrange + var (_, commandFactory) = CreateToolLoader(); + var availableCommands = CommandFactory.GetVisibleCommands(commandFactory.AllCommands).ToList(); + + // Skip test if no commands are available + if (!availableCommands.Any()) + { + return; + } + + var specificToolName = availableCommands.First().Key; + var toolOptions = new ToolLoaderOptions { Tool = [specificToolName.ToUpperInvariant()] }; // Test case insensitive + var (toolLoader, _) = CreateToolLoader(toolOptions); + var request = CreateRequest(); + + // Act + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Tools); + Assert.Single(result.Tools); + Assert.Equal(specificToolName, result.Tools[0].Name); + } + [Fact] public async Task ListToolsHandler_WithServiceFilter_ReturnsOnlyFilteredTools() { @@ -112,7 +186,7 @@ public async Task ListToolsHandler_WithServiceFilter_ReturnsOnlyFilteredTools() try { - var result = await toolLoader.ListToolsHandler(request, CancellationToken.None); + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); // Verify basic structure Assert.NotNull(result); @@ -151,7 +225,7 @@ public async Task ListToolsHandler_WithMultipleServiceFilters_ReturnsToolsFromAl try { - var result = await toolLoader.ListToolsHandler(request, CancellationToken.None); + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); // Verify basic structure Assert.NotNull(result); @@ -190,7 +264,7 @@ public async Task ListToolsHandler_WithMultipleServiceFilters_ReturnsToolsFromAl // Verify that tools from non-specified services are not included var allToolsOptions = new ToolLoaderOptions(); // No filter = all tools var (allToolsLoader, _) = CreateToolLoader(allToolsOptions); - var allToolsResult = await allToolsLoader.ListToolsHandler(request, CancellationToken.None); + var allToolsResult = await allToolsLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); var excludedTools = allToolsResult.Tools.Where(t => !existingServices.Any(service => @@ -226,8 +300,8 @@ public async Task CallToolHandler_WithValidTool_ExecutesSuccessfully() var availableCommands = CommandFactory.GetVisibleCommands(commandFactory.AllCommands); var firstCommand = availableCommands.First(); - var mockServer = Substitute.For(); - var request = new ModelContextProtocol.Server.RequestContext(mockServer) + var mockServer = Substitute.For(); + var request = new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) { Params = new CallToolRequestParams { @@ -236,7 +310,7 @@ public async Task CallToolHandler_WithValidTool_ExecutesSuccessfully() } }; - var result = await toolLoader.CallToolHandler(request, CancellationToken.None); + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.NotNull(result.Content); @@ -248,13 +322,13 @@ public async Task CallToolHandler_WithNullParams_ReturnsError() { var (toolLoader, _) = CreateToolLoader(); - var mockServer = Substitute.For(); - var request = new ModelContextProtocol.Server.RequestContext(mockServer) + var mockServer = Substitute.For(); + var request = new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) { Params = null }; - var result = await toolLoader.CallToolHandler(request, CancellationToken.None); + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.True(result.IsError); @@ -271,8 +345,8 @@ public async Task CallToolHandler_WithUnknownTool_ReturnsError() { var (toolLoader, _) = CreateToolLoader(); - var mockServer = Substitute.For(); - var request = new ModelContextProtocol.Server.RequestContext(mockServer) + var mockServer = Substitute.For(); + var request = new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) { Params = new CallToolRequestParams { @@ -281,7 +355,7 @@ public async Task CallToolHandler_WithUnknownTool_ReturnsError() } }; - var result = await toolLoader.CallToolHandler(request, CancellationToken.None); + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.True(result.IsError); @@ -302,7 +376,7 @@ public async Task GetsToolsWithRawMcpInputOption() }; var (toolLoader, _) = CreateToolLoader(filteredOptions); var request = CreateRequest(); - var result = await toolLoader.ListToolsHandler(request, CancellationToken.None); + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.NotEmpty(result.Tools); @@ -362,10 +436,10 @@ public async Task CallToolHandler_BeforeListToolsHandler_ExecutesSuccessfully() var targetCommand = subscriptionListCommand; - var mockServer = Substitute.For(); + var mockServer = Substitute.For(); var arguments = new Dictionary(); - var callToolRequest = new ModelContextProtocol.Server.RequestContext(mockServer) + var callToolRequest = new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) { Params = new CallToolRequestParams { @@ -375,7 +449,7 @@ public async Task CallToolHandler_BeforeListToolsHandler_ExecutesSuccessfully() }; // Act - Call CallToolHandler BEFORE ListToolsHandler - var callResult = await toolLoader.CallToolHandler(callToolRequest, CancellationToken.None); + var callResult = await toolLoader.CallToolHandler(callToolRequest, TestContext.Current.CancellationToken); // Assert based on what we know might happen Assert.NotNull(callResult); @@ -394,7 +468,7 @@ public async Task CallToolHandler_BeforeListToolsHandler_ExecutesSuccessfully() // Now call ListToolsHandler to verify it still works after CallToolHandler var listToolsRequest = CreateRequest(); - var listResult = await toolLoader.ListToolsHandler(listToolsRequest, CancellationToken.None); + var listResult = await toolLoader.ListToolsHandler(listToolsRequest, TestContext.Current.CancellationToken); // Assert that ListToolsHandler still works Assert.NotNull(listResult); @@ -418,10 +492,10 @@ public async Task ListToolsHandler_ReturnsToolWithArrayOrCollectionProperty() var request = CreateRequest(); // Act - var result = await toolLoader.ListToolsHandler(request, CancellationToken.None); + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); // Find the appconfig_kv_set tool and print all tool names - var appConfigSetTool = result.Tools.FirstOrDefault(t => t.Name == "azmcp_appconfig_kv_set"); + var appConfigSetTool = result.Tools.FirstOrDefault(t => t.Name == "appconfig_kv_set"); // Assert Assert.NotNull(appConfigSetTool); @@ -474,7 +548,7 @@ public async Task ListToolsHandler_ToolsWithSecretMetadata_HaveSecretHintInMeta( var request = CreateRequest(); // Act - var result = await toolLoader.ListToolsHandler(request, CancellationToken.None); + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -503,7 +577,7 @@ public async Task CallToolHandler_WithSecretTool_WhenClientDoesNotSupportElicita fakeCommand.GetCommand().Returns(fakeSystemCommand); fakeCommand.Title.Returns("Fake Secret Get"); fakeCommand.Metadata.Returns(new ToolMetadata { Secret = true }); - fakeCommand.ExecuteAsync(Arg.Any(), Arg.Any()) + fakeCommand.ExecuteAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new CommandResponse { Status = HttpStatusCode.OK, Message = "Secret test response" }); // Add our fake command to the internal command map using reflection @@ -512,10 +586,10 @@ public async Task CallToolHandler_WithSecretTool_WhenClientDoesNotSupportElicita commandMap["fake-secret-get"] = fakeCommand; // Create mock server without elicitation capabilities - var mockServer = Substitute.For(); + var mockServer = Substitute.For(); mockServer.ClientCapabilities.Returns((ClientCapabilities?)null); - var request = new ModelContextProtocol.Server.RequestContext(mockServer) + var request = new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) { Params = new CallToolRequestParams { @@ -524,7 +598,7 @@ public async Task CallToolHandler_WithSecretTool_WhenClientDoesNotSupportElicita } }; - var result = await toolLoader.CallToolHandler(request, CancellationToken.None); + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); // Should reject execution as client doesn't support elicitation (security requirement) Assert.NotNull(result); @@ -543,7 +617,7 @@ public async Task CallToolHandler_WithNonSecretTool_DoesNotTriggerElicitation() fakeCommand.GetCommand().Returns(fakeSystemCommand); fakeCommand.Title.Returns("Fake Non-Secret Get"); fakeCommand.Metadata.Returns(new ToolMetadata { Secret = false }); // Not secret - fakeCommand.ExecuteAsync(Arg.Any(), Arg.Any()) + fakeCommand.ExecuteAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new CommandResponse { Status = HttpStatusCode.OK, Message = "Test response" }); // Add our fake command to the internal command map using reflection @@ -552,11 +626,11 @@ public async Task CallToolHandler_WithNonSecretTool_DoesNotTriggerElicitation() commandMap["fake-non-secret-get"] = fakeCommand; // Create mock server with elicitation capabilities - var mockServer = Substitute.For(); + var mockServer = Substitute.For(); var capabilities = new ClientCapabilities { Elicitation = new ElicitationCapability() }; mockServer.ClientCapabilities.Returns(capabilities); - var request = new ModelContextProtocol.Server.RequestContext(mockServer) + var request = new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) { Params = new CallToolRequestParams { @@ -565,7 +639,7 @@ public async Task CallToolHandler_WithNonSecretTool_DoesNotTriggerElicitation() } }; - var result = await toolLoader.CallToolHandler(request, CancellationToken.None); + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); // Should execute without issues for non-secret tools Assert.NotNull(result); @@ -585,7 +659,7 @@ public async Task CallToolHandler_WithSecretTool_WhenInsecureDisableElicitationE fakeCommand.GetCommand().Returns(fakeSystemCommand); fakeCommand.Title.Returns("Fake Secret Get"); fakeCommand.Metadata.Returns(new ToolMetadata { Secret = true }); - fakeCommand.ExecuteAsync(Arg.Any(), Arg.Any()) + fakeCommand.ExecuteAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new CommandResponse { Status = HttpStatusCode.OK, Message = "Secret test response" }); // Add our fake command to the internal command map using reflection @@ -594,10 +668,10 @@ public async Task CallToolHandler_WithSecretTool_WhenInsecureDisableElicitationE commandMap["fake-secret-get"] = fakeCommand; // Create mock server - elicitation support doesn't matter when bypassed - var mockServer = Substitute.For(); + var mockServer = Substitute.For(); mockServer.ClientCapabilities.Returns((ClientCapabilities?)null); - var request = new ModelContextProtocol.Server.RequestContext(mockServer) + var request = new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) { Params = new CallToolRequestParams { @@ -606,7 +680,7 @@ public async Task CallToolHandler_WithSecretTool_WhenInsecureDisableElicitationE } }; - var result = await toolLoader.CallToolHandler(request, CancellationToken.None); + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); // Should execute successfully despite being a secret tool and client not supporting elicitation Assert.NotNull(result); @@ -630,7 +704,7 @@ public async Task CallToolHandler_WithSecretTool_WhenInsecureDisableElicitationD fakeCommand.GetCommand().Returns(fakeSystemCommand); fakeCommand.Title.Returns("Fake Secret Get"); fakeCommand.Metadata.Returns(new ToolMetadata { Secret = true }); - fakeCommand.ExecuteAsync(Arg.Any(), Arg.Any()) + fakeCommand.ExecuteAsync(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(new CommandResponse { Status = HttpStatusCode.OK, Message = "Secret test response" }); // Add our fake command to the internal command map using reflection @@ -639,10 +713,10 @@ public async Task CallToolHandler_WithSecretTool_WhenInsecureDisableElicitationD commandMap["fake-secret-get"] = fakeCommand; // Create mock server without elicitation capabilities - var mockServer = Substitute.For(); + var mockServer = Substitute.For(); mockServer.ClientCapabilities.Returns((ClientCapabilities?)null); - var request = new ModelContextProtocol.Server.RequestContext(mockServer) + var request = new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) { Params = new CallToolRequestParams { @@ -651,7 +725,7 @@ public async Task CallToolHandler_WithSecretTool_WhenInsecureDisableElicitationD } }; - var result = await toolLoader.CallToolHandler(request, CancellationToken.None); + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); // Should still reject execution when insecure option is disabled Assert.NotNull(result); @@ -679,5 +753,188 @@ public void ToolLoaderOptions_WithInsecureDisableElicitationTrue_IsSetCorrectly( Assert.True(options.InsecureDisableElicitation); } + [Fact] + public async Task CallToolHandler_WithToolFilter_AllowsSpecifiedTool() + { + // Arrange + var (_, commandFactory) = CreateToolLoader(); + var availableCommands = CommandFactory.GetVisibleCommands(commandFactory.AllCommands).ToList(); + + // Skip test if no commands are available + if (!availableCommands.Any()) + { + return; + } + + var specificToolName = availableCommands.First().Key; + var toolOptions = new ToolLoaderOptions { Tool = [specificToolName] }; + var (toolLoader, _) = CreateToolLoader(toolOptions); + + var mockServer = Substitute.For(); + var request = new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = new CallToolRequestParams + { + Name = specificToolName, + Arguments = new Dictionary() + } + }; + + // Act + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert - Should not reject due to tool filtering + Assert.NotNull(result); + // Note: The result might still be an error for other reasons (like missing parameters), + // but it should not be rejected specifically due to tool filtering + if (result.IsError == true) + { + var errorText = ((TextContentBlock)result.Content.First()).Text; + Assert.DoesNotContain("is not available", errorText); + Assert.DoesNotContain("only expose the tool", errorText); + } + } + + [Fact] + public async Task CallToolHandler_WithToolFilter_RejectsNonSpecifiedTool() + { + // Arrange + var (_, commandFactory) = CreateToolLoader(); + var availableCommands = CommandFactory.GetVisibleCommands(commandFactory.AllCommands).ToList(); + + // Skip test if fewer than 2 commands are available + if (availableCommands.Count < 2) + { + return; + } + + var specificToolName = availableCommands.First().Key; + var otherToolName = availableCommands.Skip(1).First().Key; + var toolOptions = new ToolLoaderOptions { Tool = [specificToolName] }; + var (toolLoader, _) = CreateToolLoader(toolOptions); + + var mockServer = Substitute.For(); + var request = new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = new CallToolRequestParams + { + Name = otherToolName, // Request a different tool than the filtered one + Arguments = new Dictionary() + } + }; + + // Act + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.True(result.IsError); + var errorText = ((TextContentBlock)result.Content.First()).Text; + Assert.Contains("is not available", errorText); + Assert.Contains("only expose the tool", errorText); + Assert.Contains(specificToolName, errorText); + } + + [Fact] + public async Task CallToolHandler_WithToolFilterCaseInsensitive_AllowsSpecifiedTool() + { + // Arrange + var (_, commandFactory) = CreateToolLoader(); + var availableCommands = CommandFactory.GetVisibleCommands(commandFactory.AllCommands).ToList(); + + // Skip test if no commands are available + if (!availableCommands.Any()) + { + return; + } + + var specificToolName = availableCommands.First().Key; + var toolOptions = new ToolLoaderOptions { Tool = [specificToolName.ToUpperInvariant()] }; // Set filter to uppercase + var (toolLoader, _) = CreateToolLoader(toolOptions); + + var mockServer = Substitute.For(); + var request = new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = new CallToolRequestParams + { + Name = specificToolName, // Request with original case + Arguments = new Dictionary() + } + }; + + // Act + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert - Should not reject due to tool filtering (case insensitive match) + Assert.NotNull(result); + if (result.IsError == true) + { + var errorText = ((TextContentBlock)result.Content.First()).Text; + Assert.DoesNotContain("is not available", errorText); + Assert.DoesNotContain("only expose the tool", errorText); + } + } + + [Fact] + public void ToolLoaderOptions_WithTool_IsSetCorrectly() + { + // Arrange & Act + var expectedTools = new[] { "azmcp_group_list" }; + var options = new ToolLoaderOptions(Tool: expectedTools); + + // Assert + Assert.Equal(expectedTools, options.Tool); + } + + [Fact] + public void ToolLoaderOptions_WithMultipleTools_IsSetCorrectly() + { + // Arrange & Act + var expectedTools = new[] { "azmcp_acr_registry_list", "azmcp_group_list" }; + var options = new ToolLoaderOptions(Tool: expectedTools); + + // Assert + Assert.Equal(expectedTools, options.Tool); + } + + [Fact] + public async Task ListToolsHandler_WithMultipleToolFilter_ReturnsSpecifiedTools() + { + // Arrange + var (toolLoader, commandFactory) = CreateToolLoader(); + var allCommands = CommandFactory.GetVisibleCommands(commandFactory.AllCommands); + + // Skip test if we don't have at least 2 commands + if (allCommands.Count() < 2) + { + return; + } + + var toolNames = allCommands.Take(2).Select(kvp => kvp.Key).ToArray(); + var toolOptions = new ToolLoaderOptions { Tool = toolNames }; + var (filteredToolLoader, _) = CreateToolLoader(toolOptions); + var request = CreateRequest(); + + // Act + var result = await filteredToolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Tools); + Assert.Equal(2, result.Tools.Count); + Assert.Contains(result.Tools, t => t.Name == toolNames[0]); + Assert.Contains(result.Tools, t => t.Name == toolNames[1]); + } + + [Fact] + public void ToolLoaderOptions_DefaultTool_IsNull() + { + // Arrange & Act + var options = new ToolLoaderOptions(); + + // Assert + Assert.Null(options.Tool); + } + #endregion } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/CompositeToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/CompositeToolLoaderTests.cs index a14ac38980..b3dc84af33 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/CompositeToolLoaderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/CompositeToolLoaderTests.cs @@ -21,8 +21,8 @@ private static IServiceProvider CreateServiceProvider() private static RequestContext CreateListToolsRequest() { - var mockServer = Substitute.For(); - return new RequestContext(mockServer) + var mockServer = Substitute.For(); + return new RequestContext(mockServer, new() { Method = RequestMethods.ToolsList }) { Params = new ListToolsRequestParams() }; @@ -30,8 +30,8 @@ private static RequestContext CreateListToolsRequest() private static RequestContext CreateCallToolRequest(string toolName, IReadOnlyDictionary? arguments = null) { - var mockServer = Substitute.For(); - return new RequestContext(mockServer) + var mockServer = Substitute.For(); + return new RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) { Params = new CallToolRequestParams { @@ -89,7 +89,7 @@ public async Task ListToolsHandler_WithSingleToolLoader_ReturnsToolsFromLoader() var toolLoader = new CompositeToolLoader(toolLoaders, logger); var request = CreateListToolsRequest(); - var result = await toolLoader.ListToolsHandler(request, CancellationToken.None); + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.NotNull(result.Tools); @@ -119,7 +119,7 @@ public async Task ListToolsHandler_WithMultipleToolLoaders_CombinesAllTools() var toolLoader = new CompositeToolLoader(toolLoaders, logger); var request = CreateListToolsRequest(); - var result = await toolLoader.ListToolsHandler(request, CancellationToken.None); + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.NotNull(result.Tools); @@ -143,7 +143,7 @@ public async Task ListToolsHandler_WithToolLoaderReturningNull_ReturnsEmptyResul var toolLoader = new CompositeToolLoader(toolLoaders, logger); var request = CreateListToolsRequest(); - var result = await toolLoader.ListToolsHandler(request, CancellationToken.None); + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Empty(result.Tools); @@ -179,11 +179,11 @@ public async Task CallToolHandler_WithUnknownTool_ReturnsErrorResult() // First populate the tool map by calling ListToolsHandler var listRequest = CreateListToolsRequest(); - await toolLoader.ListToolsHandler(listRequest, CancellationToken.None); + await toolLoader.ListToolsHandler(listRequest, TestContext.Current.CancellationToken); // Now try to call an unknown tool var callRequest = CreateCallToolRequest("unknown-tool"); - var result = await toolLoader.CallToolHandler(callRequest, CancellationToken.None); + var result = await toolLoader.CallToolHandler(callRequest, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.True(result.IsError); @@ -218,11 +218,11 @@ public async Task CallToolHandler_WithKnownTool_DelegatesToCorrectLoader() // First populate the tool map var listRequest = CreateListToolsRequest(); - await toolLoader.ListToolsHandler(listRequest, CancellationToken.None); + await toolLoader.ListToolsHandler(listRequest, TestContext.Current.CancellationToken); // Now call the known tool var callRequest = CreateCallToolRequest("test-tool"); - var result = await toolLoader.CallToolHandler(callRequest, CancellationToken.None); + var result = await toolLoader.CallToolHandler(callRequest, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.False(result.IsError); @@ -261,11 +261,11 @@ public async Task CallToolHandler_WithMultipleLoaders_DelegatesToCorrectLoader() // First populate the tool map var listRequest = CreateListToolsRequest(); - await toolLoader.ListToolsHandler(listRequest, CancellationToken.None); + await toolLoader.ListToolsHandler(listRequest, TestContext.Current.CancellationToken); // Call tool2 which should be handled by mockLoader2 var callRequest = CreateCallToolRequest("tool2"); - var result = await toolLoader.CallToolHandler(callRequest, CancellationToken.None); + var result = await toolLoader.CallToolHandler(callRequest, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.False(result.IsError); @@ -285,13 +285,13 @@ public async Task CallToolHandler_WithNullParams_ReturnsErrorResult() var toolLoaders = new List { mockToolLoader }; var toolLoader = new CompositeToolLoader(toolLoaders, logger); - var mockServer = Substitute.For(); - var request = new RequestContext(mockServer) + var mockServer = Substitute.For(); + var request = new RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) { Params = null }; - var result = await toolLoader.CallToolHandler(request, CancellationToken.None); + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.True(result.IsError); @@ -315,7 +315,7 @@ public async Task CallToolHandler_WithToolLoaderReturningNull_ReturnsErrorResult var toolLoader = new CompositeToolLoader(toolLoaders, logger); var request = CreateCallToolRequest("test-tool"); - var result = await toolLoader.CallToolHandler(request, CancellationToken.None); + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.True(result.IsError); @@ -338,7 +338,7 @@ public async Task ListToolsHandler_WithSingleEmptyToolLoader_ReturnsEmptyResult( var toolLoader = new CompositeToolLoader(toolLoaders, logger); var request = CreateListToolsRequest(); - var result = await toolLoader.ListToolsHandler(request, CancellationToken.None); + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.NotNull(result.Tools); @@ -370,7 +370,7 @@ public async Task CallToolHandler_WithoutListingToolsFirst_LazilyInitializesAndC // Call tool directly WITHOUT first calling ListToolsHandler var callRequest = CreateCallToolRequest("test-tool"); - var result = await toolLoader.CallToolHandler(callRequest, CancellationToken.None); + var result = await toolLoader.CallToolHandler(callRequest, TestContext.Current.CancellationToken); // Verify the tool was found and executed successfully Assert.NotNull(result); @@ -404,7 +404,7 @@ public async Task CallToolHandler_WithoutListingToolsFirst_ReturnsErrorForUnknow // Call tool directly WITHOUT first calling ListToolsHandler var callRequest = CreateCallToolRequest("unknown-tool"); - var result = await toolLoader.CallToolHandler(callRequest, CancellationToken.None); + var result = await toolLoader.CallToolHandler(callRequest, TestContext.Current.CancellationToken); // Verify the tool was not found Assert.NotNull(result); @@ -451,7 +451,7 @@ public async Task CallToolHandler_ConcurrentCallsWithoutListingFirst_Initializes for (int i = 0; i < concurrentCalls; i++) { var callRequest = CreateCallToolRequest("test-tool"); - tasks.Add(toolLoader.CallToolHandler(callRequest, CancellationToken.None).AsTask()); + tasks.Add(toolLoader.CallToolHandler(callRequest, TestContext.Current.CancellationToken).AsTask()); } var results = await Task.WhenAll(tasks); @@ -502,7 +502,7 @@ public async Task CallToolHandler_ValidToolWithoutPriorListingCall_ExecutesSucce // Act - Call the tool directly without first calling ListToolsHandler var callRequest = CreateCallToolRequest("valid-tool"); - var result = await compositeToolLoader.CallToolHandler(callRequest, CancellationToken.None); + var result = await compositeToolLoader.CallToolHandler(callRequest, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -534,7 +534,7 @@ public async Task CallToolHandler_WithToolLoaderThrowingException_ReturnsErrorRe var toolLoader = new CompositeToolLoader(toolLoaders, logger); var request = CreateCallToolRequest("test-tool"); - var result = await toolLoader.CallToolHandler(request, CancellationToken.None); + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.True(result.IsError); diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs new file mode 100644 index 0000000000..9e4f75aa56 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs @@ -0,0 +1,624 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.CommandLine; +using System.Net; +using System.Text.Json; +using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.UnitTests.Areas.Server; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Protocol; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.ToolLoading; + +public sealed class NamespaceToolLoaderTests : IDisposable +{ + private readonly ServiceProvider _serviceProvider; + private readonly CommandFactory _commandFactory; + private readonly IOptions _options; + private readonly ILogger _logger; + + public NamespaceToolLoaderTests() + { + _serviceProvider = CommandFactoryHelpers.CreateDefaultServiceProvider() as ServiceProvider + ?? throw new InvalidOperationException("Failed to create service provider"); + _commandFactory = CommandFactoryHelpers.CreateCommandFactory(_serviceProvider); + _options = Microsoft.Extensions.Options.Options.Create(new ServiceStartOptions()); + _logger = _serviceProvider.GetRequiredService>(); + } + + [Fact] + public void Constructor_InitializesSuccessfully() + { + // Arrange & Act + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + + // Assert + Assert.NotNull(loader); + } + + [Fact] + public void Constructor_ThrowsOnNullCommandFactory() + { + // Arrange & Act & Assert + Assert.Throws(() => + new NamespaceToolLoader(null!, _options, _serviceProvider, _logger)); + } + + [Fact] + public void Constructor_ThrowsOnNullOptions() + { + // Arrange & Act & Assert + Assert.Throws(() => + new NamespaceToolLoader(_commandFactory, null!, _serviceProvider, _logger)); + } + + [Fact] + public void Constructor_ThrowsOnNullServiceProvider() + { + // Arrange & Act & Assert + Assert.Throws(() => + new NamespaceToolLoader(_commandFactory, _options, null!, _logger)); + } + + [Fact] + public async Task ListToolsHandler_ReturnsNamespaceTools() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var request = CreateListToolsRequest(); + + // Act + var result = await loader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.Tools); + Assert.NotEmpty(result.Tools); + + // Verify hierarchical structure + foreach (var tool in result.Tools) + { + Assert.NotNull(tool.Name); + Assert.NotNull(tool.Description); + Assert.Contains("hierarchical", tool.Description, StringComparison.OrdinalIgnoreCase); + + // Verify hierarchical schema structure + var schema = tool.InputSchema; + Assert.True(schema.TryGetProperty("properties", out var properties)); + Assert.True(properties.TryGetProperty("intent", out _)); + Assert.True(properties.TryGetProperty("command", out _)); + Assert.True(properties.TryGetProperty("parameters", out _)); + Assert.True(properties.TryGetProperty("learn", out _)); + } + } + + [Fact] + public async Task ListToolsHandler_CachesResults() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var request = CreateListToolsRequest(); + + // Act - Call twice + var result1 = await loader.ListToolsHandler(request, TestContext.Current.CancellationToken); + var result2 = await loader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert - Should return same cached instance + Assert.Same(result1.Tools, result2.Tools); + } + + [Fact] + public async Task ListToolsHandler_FiltersNamespacesWhenConfigured() + { + // Arrange + using var serviceProvider = CommandFactoryHelpers.CreateDefaultServiceProvider() as ServiceProvider + ?? throw new InvalidOperationException("Failed to create service provider"); + var commandFactory = CommandFactoryHelpers.CreateCommandFactory(serviceProvider); + var options = Microsoft.Extensions.Options.Options.Create(new ServiceStartOptions + { + Namespace = ["storage", "keyvault"] + }); + var logger = serviceProvider.GetRequiredService>(); + + var loader = new NamespaceToolLoader(commandFactory, options, serviceProvider, logger); + var request = CreateListToolsRequest(); + + // Act + var result = await loader.ListToolsHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result.Tools); + Assert.All(result.Tools, tool => + Assert.True(tool.Name == "storage" || tool.Name == "keyvault")); + } + + [Fact] + public async Task CallToolHandler_WithLearnTrue_ReturnsAvailableCommands() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + var request = CreateCallToolRequest(toolName, new Dictionary + { + ["learn"] = true, + ["intent"] = "list resources" + }); + + // Act + var result = await loader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.False(result.IsError); + Assert.NotNull(result.Content); + Assert.Single(result.Content); + + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("available command", textContent.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CallToolHandler_WithLearnTrue_CachesCommandList() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + var request = CreateCallToolRequest(toolName, new Dictionary + { + ["learn"] = true, + ["intent"] = "list resources" + }); + + // Act - Call twice + var result1 = await loader.CallToolHandler(request, TestContext.Current.CancellationToken); + var result2 = await loader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert - Both should succeed and return same cached content + Assert.False(result1.IsError); + Assert.False(result2.IsError); + + var text1 = (result1.Content[0] as TextContentBlock)?.Text; + var text2 = (result2.Content[0] as TextContentBlock)?.Text; + Assert.Equal(text1, text2); + } + + [Fact] + public async Task CallToolHandler_WithIntentButNoCommand_AutoEnablesLearn() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + var request = CreateCallToolRequest(toolName, new Dictionary + { + ["intent"] = "list resources" + // No command specified, should auto-enable learn + }); + + // Act + var result = await loader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.False(result.IsError); + + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("available command", textContent.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CallToolHandler_WithInvalidNamespace_ReturnsError() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var request = CreateCallToolRequest("nonexistent-namespace", new Dictionary + { + ["learn"] = true + }); + + // Act + var result = await loader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.True(result.IsError); + + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("not found", textContent.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CallToolHandler_WithNullToolName_ThrowsArgumentException() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var request = CreateCallToolRequest(null!, new Dictionary()); + + // Act & Assert + await Assert.ThrowsAsync(async () => + await loader.CallToolHandler(request, TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task CallToolHandler_WithoutCommandOrLearn_ReturnsHelpMessage() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + var request = CreateCallToolRequest(toolName, new Dictionary()); + + // Act + var result = await loader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.False(result.IsError); + + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("command", textContent.Text, StringComparison.OrdinalIgnoreCase); + Assert.Contains("learn", textContent.Text, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task CallToolHandler_ParsesHierarchicalStructure() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + + var arguments = new Dictionary + { + ["intent"] = JsonDocument.Parse("\"list resources\"").RootElement, + ["command"] = JsonDocument.Parse("\"list\"").RootElement, + ["parameters"] = JsonDocument.Parse("""{"subscription":"test-sub"}""").RootElement, + ["learn"] = JsonDocument.Parse("false").RootElement + }; + + var request = CreateCallToolRequestWithJsonElements(toolName, arguments); + + // Act + var result = await loader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + // Result depends on whether command exists, but parsing should succeed + } + + [Fact] + public async Task CallToolHandler_ConvertsObjectDictionaryToJsonElements() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + + var arguments = new Dictionary + { + ["intent"] = "list resources", + ["command"] = "list", + ["parameters"] = new Dictionary { ["subscription"] = "test-sub" }, + ["learn"] = false + }; + + var request = CreateCallToolRequest(toolName, arguments); + + // Act + var result = await loader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + // Conversion should succeed without throwing + } + + [Fact] + public async Task CallToolHandler_HandlesCommandNotFoundGracefully() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + + var request = CreateCallToolRequest(toolName, new Dictionary + { + ["intent"] = "do something", + ["command"] = "nonexistent-command", + ["parameters"] = new Dictionary() + }); + + // Act + var result = await loader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + // Should fallback to learn mode or return error + var textContent = result.Content[0] as TextContentBlock; + Assert.NotNull(textContent); + } + + [Fact] + public async Task CallToolHandler_LazyLoadsCommandsPerNamespace() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + + // Get two different namespaces + var listRequest = CreateListToolsRequest(); + var tools = await loader.ListToolsHandler(listRequest, TestContext.Current.CancellationToken); + + if (tools.Tools.Count < 2) + { + // Skip test if not enough namespaces + return; + } + + var namespace1 = tools.Tools[0].Name; + var namespace2 = tools.Tools[1].Name; + + // Act - Access only first namespace + var request1 = CreateCallToolRequest(namespace1, new Dictionary + { + ["learn"] = true, + ["intent"] = "test" + }); + + await loader.CallToolHandler(request1, TestContext.Current.CancellationToken); + + // Now access second namespace + var request2 = CreateCallToolRequest(namespace2, new Dictionary + { + ["learn"] = true, + ["intent"] = "test" + }); + + var result2 = await loader.CallToolHandler(request2, TestContext.Current.CancellationToken); + + // Assert - Both should succeed, proving lazy loading works + Assert.NotNull(result2); + Assert.False(result2.IsError); + } + + [Fact] + public async Task CallToolHandler_ThreadSafeLazyLoading() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + + // Act - Simulate concurrent access + var tasks = Enumerable.Range(0, 10).Select(async _ => + { + var request = CreateCallToolRequest(toolName, new Dictionary + { + ["learn"] = true, + ["intent"] = "concurrent test" + }); + + return await loader.CallToolHandler(request, TestContext.Current.CancellationToken); + }); + + var results = await Task.WhenAll(tasks); + + // Assert - All should succeed without race conditions + Assert.All(results, result => + { + Assert.NotNull(result); + Assert.False(result.IsError); + }); + + // All should return same cached content + var firstText = (results[0].Content[0] as TextContentBlock)?.Text; + Assert.All(results, result => + { + var text = (result.Content[0] as TextContentBlock)?.Text; + Assert.Equal(firstText, text); + }); + } + + [Fact] + public async Task DisposeAsync_ClearsCaches() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var toolName = GetFirstAvailableNamespace(); + + // Populate cache + var request = CreateCallToolRequest(toolName, new Dictionary + { + ["learn"] = true, + ["intent"] = "test" + }); + + await loader.CallToolHandler(request, TestContext.Current.CancellationToken); + + // Act + await loader.DisposeAsync(); + + // Assert - No exception should be thrown + // Cache clearing is internal, but disposal should complete successfully + } + + // Elicitation Handler Tests (ported from BaseToolLoaderTests) + + [Fact] + public void CreateClientOptions_WithElicitationCapability_ReturnsOptionsWithElicitationHandler() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var mockServer = Substitute.For(); + var capabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability() + }; + mockServer.ClientCapabilities.Returns(capabilities); + + // Act + var options = CallCreateClientOptions(loader, mockServer); + + // Assert + Assert.NotNull(options); + Assert.NotNull(options.Handlers); + Assert.NotNull(options.Handlers.ElicitationHandler); + } + + [Fact] + public void CreateClientOptions_WithNoElicitationCapability_ReturnsOptionsWithoutElicitationHandler() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var mockServer = Substitute.For(); + mockServer.ClientCapabilities.Returns(new ClientCapabilities()); + + // Act + var options = CallCreateClientOptions(loader, mockServer); + + // Assert + Assert.NotNull(options); + Assert.NotNull(options.Handlers); + Assert.Null(options.Handlers.ElicitationHandler); + } + + [Fact] + public async Task CreateClientOptions_ElicitationHandler_DelegatesToServerSendRequestAsync() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var mockServer = Substitute.For(); + var capabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability() + }; + mockServer.ClientCapabilities.Returns(capabilities); + + var elicitationRequest = new ElicitRequestParams + { + Message = "Please enter your password:" + }; + + var mockResponse = new JsonRpcResponse + { + Id = new RequestId(1), + Result = JsonSerializer.SerializeToNode(new ElicitResult { Action = "accept" }) + }; + + mockServer.SendRequestAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(mockResponse)); + + // Act + var options = CallCreateClientOptions(loader, mockServer); + Assert.NotNull(options.Handlers.ElicitationHandler); + + await options.Handlers.ElicitationHandler(elicitationRequest, TestContext.Current.CancellationToken); + + // Assert - verify SendRequestAsync was called with elicitation method + await mockServer.Received(1).SendRequestAsync( + Arg.Is(req => req.Method == "elicitation/create"), + Arg.Any()); + } + + [Fact] + public async Task CreateClientOptions_ElicitationHandler_ValidatesRequestAndThrowsOnNull() + { + // Arrange + var loader = new NamespaceToolLoader(_commandFactory, _options, _serviceProvider, _logger); + var mockServer = Substitute.For(); + var capabilities = new ClientCapabilities + { + Elicitation = new ElicitationCapability() + }; + mockServer.ClientCapabilities.Returns(capabilities); + + // Act + var options = CallCreateClientOptions(loader, mockServer); + Assert.NotNull(options.Handlers.ElicitationHandler); + + // Assert - verify handler validates null request + await Assert.ThrowsAsync(async () => + await options.Handlers.ElicitationHandler.Invoke(null!, TestContext.Current.CancellationToken)); + } + + // Helper methods + + private string GetFirstAvailableNamespace() + { + var namespaces = _commandFactory.RootGroup.SubGroup + .Where(g => !Azure.Mcp.Core.Areas.Server.Commands.Discovery.DiscoveryConstants.IgnoredCommandGroups.Contains(g.Name, StringComparer.OrdinalIgnoreCase)) + .Select(g => g.Name) + .ToList(); + + return namespaces.FirstOrDefault() ?? "storage"; + } + + private static ModelContextProtocol.Server.RequestContext CreateListToolsRequest() + { + var mockServer = Substitute.For(); + return new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsList }) + { + Params = new ListToolsRequestParams() + }; + } + + private static ModelContextProtocol.Server.RequestContext CreateCallToolRequest( + string toolName, + Dictionary arguments) + { + var jsonArguments = arguments.ToDictionary( + kvp => kvp.Key, + kvp => JsonSerializer.SerializeToElement(kvp.Value)); + + var mockServer = Substitute.For(); + return new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = new CallToolRequestParams + { + Name = toolName, + Arguments = jsonArguments + } + }; + } + + private static ModelContextProtocol.Server.RequestContext CreateCallToolRequestWithJsonElements( + string toolName, + Dictionary arguments) + { + var mockServer = Substitute.For(); + return new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) + { + Params = new CallToolRequestParams + { + Name = toolName, + Arguments = arguments + } + }; + } + + private static ModelContextProtocol.Client.McpClientOptions CallCreateClientOptions( + NamespaceToolLoader loader, + ModelContextProtocol.Server.McpServer server) + { + // Use reflection to call the protected CreateClientOptions method + var method = typeof(BaseToolLoader).GetMethod( + "CreateClientOptions", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + + if (method == null) + { + throw new InvalidOperationException("CreateClientOptions method not found on BaseToolLoader"); + } + + var result = method.Invoke(loader, [server]); + return (ModelContextProtocol.Client.McpClientOptions)result!; + } + public void Dispose() + { + _serviceProvider?.Dispose(); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/RegistryToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/RegistryToolLoaderTests.cs index 372f02cb61..28bb23b770 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/RegistryToolLoaderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/RegistryToolLoaderTests.cs @@ -29,8 +29,8 @@ private static (RegistryToolLoader toolLoader, IMcpDiscoveryStrategy mockDiscove private static ModelContextProtocol.Server.RequestContext CreateListToolsRequest() { - var mockServer = Substitute.For(); - return new ModelContextProtocol.Server.RequestContext(mockServer) + var mockServer = Substitute.For(); + return new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsList }) { Params = new ListToolsRequestParams() }; @@ -38,8 +38,8 @@ private static ModelContextProtocol.Server.RequestContext CreateCallToolRequest(string toolName, IReadOnlyDictionary? arguments = null) { - var mockServer = Substitute.For(); - return new ModelContextProtocol.Server.RequestContext(mockServer) + var mockServer = Substitute.For(); + return new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) { Params = new CallToolRequestParams { @@ -56,11 +56,11 @@ public async Task ListToolsHandler_WithNoServers_ReturnsEmptyToolList() var (toolLoader, mockDiscoveryStrategy) = CreateToolLoader(); var request = CreateListToolsRequest(); - mockDiscoveryStrategy.DiscoverServersAsync() + mockDiscoveryStrategy.DiscoverServersAsync(TestContext.Current.CancellationToken) .Returns(Task.FromResult(Enumerable.Empty())); // Act - var result = await toolLoader.ListToolsHandler(request, CancellationToken.None); + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -89,7 +89,7 @@ public async Task ListToolsHandler_WithMockServerProvider_ReturnsExpectedStructu var request = CreateListToolsRequest(); // Act - var result = await toolLoader.ListToolsHandler(request, CancellationToken.None); + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -137,7 +137,7 @@ public async Task ListToolsHandler_WithReadOnlyOption_FiltersProperly() var request = CreateListToolsRequest(); // Act - var result = await toolLoader.ListToolsHandler(request, CancellationToken.None); + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -191,7 +191,7 @@ public async Task ListToolsHandler_WithReadOnlyDisabled_ReturnsAllTools() var request = CreateListToolsRequest(); // Act - var result = await toolLoader.ListToolsHandler(request, CancellationToken.None); + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -217,7 +217,7 @@ public async Task CallToolHandler_WithUnknownTool_ReturnsErrorResult() var request = CreateCallToolRequest("unknown-tool"); // Act - var result = await toolLoader.CallToolHandler(request, CancellationToken.None); + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -257,8 +257,8 @@ public async Task RegistryToolLoader_WithDifferentOptions_BehavesConsistently() var request = CreateListToolsRequest(); // Act - var defaultResult = await defaultToolLoader.ListToolsHandler(request, CancellationToken.None); - var readOnlyResult = await readOnlyToolLoader.ListToolsHandler(request, CancellationToken.None); + var defaultResult = await defaultToolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); + var readOnlyResult = await readOnlyToolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); // Assert - Both should return empty but valid results Assert.NotNull(defaultResult); @@ -302,7 +302,7 @@ public async Task CallToolHandler_WithoutListToolsFirst_ShouldSucceed() }); // Act - Call CallToolHandler, which should initialize tools first - var result = await toolLoader.CallToolHandler(request, CancellationToken.None); + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); // Assert - The tool call should succeed Assert.NotNull(result); @@ -353,7 +353,7 @@ public async Task MockMcpClient_WithExtensionMethods_WorksCorrectly() // Act & Assert - List tools var listRequest = CreateListToolsRequest(); - var listResult = await toolLoader.ListToolsHandler(listRequest, CancellationToken.None); + var listResult = await toolLoader.ListToolsHandler(listRequest, TestContext.Current.CancellationToken); Assert.NotNull(listResult); Assert.Equal(2, listResult.Tools.Count); Assert.Contains(listResult.Tools, t => t.Name == "docs-search"); @@ -365,7 +365,7 @@ public async Task MockMcpClient_WithExtensionMethods_WorksCorrectly() { "query", JsonDocument.Parse("\"MCP implementation\"").RootElement } }); - var searchResult = await toolLoader.CallToolHandler(searchRequest, CancellationToken.None); + var searchResult = await toolLoader.CallToolHandler(searchRequest, TestContext.Current.CancellationToken); Assert.NotNull(searchResult); Assert.False(searchResult.IsError); @@ -379,7 +379,7 @@ public async Task MockMcpClient_WithExtensionMethods_WorksCorrectly() { { "message", JsonDocument.Parse("\"Hello MCP!\"").RootElement } }); - var echoResult = await toolLoader.CallToolHandler(echoRequest, CancellationToken.None); + var echoResult = await toolLoader.CallToolHandler(echoRequest, TestContext.Current.CancellationToken); Assert.NotNull(echoResult); Assert.False(echoResult.IsError); var echoContent = echoResult.Content.OfType().FirstOrDefault(); @@ -425,7 +425,7 @@ public async Task ListToolsHandler_WithReadOnlyOption_FilterToolsWithNullAnnotat var request = CreateListToolsRequest(); // Act - var result = await toolLoader.ListToolsHandler(request, CancellationToken.None); + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -463,14 +463,14 @@ public async Task DisposeAsync_ShouldClearInternalCollections() // Initialize tool loader by calling ListToolsHandler var request = CreateListToolsRequest(); - await toolLoader.ListToolsHandler(request, CancellationToken.None); + await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); // Act await toolLoader.DisposeAsync(); // Assert - After disposal, calling operations should work but with empty state // (This tests that collections were cleared) - var result = await toolLoader.ListToolsHandler(request, CancellationToken.None); + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); Assert.NotNull(result.Tools); // Tools might be re-populated from discovery strategy, but internal state was cleared } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/ServerToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/ServerToolLoaderTests.cs index 5a460b6195..72d306f550 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/ServerToolLoaderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/ServerToolLoaderTests.cs @@ -29,8 +29,8 @@ private static (ServerToolLoader toolLoader, IMcpDiscoveryStrategy mockDiscovery private static ModelContextProtocol.Server.RequestContext CreateRequest() { - var mockServer = Substitute.For(); - return new ModelContextProtocol.Server.RequestContext(mockServer) + var mockServer = Substitute.For(); + return new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsList }) { Params = new ListToolsRequestParams() }; @@ -38,8 +38,8 @@ private static ModelContextProtocol.Server.RequestContext CreateCallToolRequest(string toolName, IReadOnlyDictionary? arguments = null) { - var mockServer = Substitute.For(); - return new ModelContextProtocol.Server.RequestContext(mockServer) + var mockServer = Substitute.For(); + return new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) { Params = new CallToolRequestParams { @@ -76,7 +76,7 @@ public async Task CallToolHandler_WithoutListToolsFirst_ShouldSucceed() // Act - Call CallToolHandler WITHOUT calling ListToolsHandler first // This should work without requiring ListToolsHandler to be called first - var result = await toolLoader.CallToolHandler(request, CancellationToken.None); + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); // Assert - The tool call should succeed Assert.NotNull(result); @@ -91,11 +91,11 @@ public async Task ListToolsHandler_WithNoServers_ReturnsEmptyToolList() var (toolLoader, mockDiscoveryStrategy) = CreateToolLoader(); var request = CreateRequest(); - mockDiscoveryStrategy.DiscoverServersAsync() + mockDiscoveryStrategy.DiscoverServersAsync(TestContext.Current.CancellationToken) .Returns(Task.FromResult(Enumerable.Empty())); // Act - var result = await toolLoader.ListToolsHandler(request, CancellationToken.None); + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -119,7 +119,7 @@ public async Task ListToolsHandler_WithRealRegistryDiscovery_ReturnsExpectedStru var request = CreateRequest(); // Act - var result = await toolLoader.ListToolsHandler(request, CancellationToken.None); + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/SingleProxyToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/SingleProxyToolLoaderTests.cs index e4854e8ca2..8d69dbe4ed 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/SingleProxyToolLoaderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/SingleProxyToolLoaderTests.cs @@ -50,8 +50,8 @@ private static (SingleProxyToolLoader toolLoader, IMcpDiscoveryStrategy discover private static ModelContextProtocol.Server.RequestContext CreateListToolsRequest() { - var mockServer = Substitute.For(); - return new ModelContextProtocol.Server.RequestContext(mockServer) + var mockServer = Substitute.For(); + return new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsList }) { Params = new ListToolsRequestParams() }; @@ -61,8 +61,8 @@ private static ModelContextProtocol.Server.RequestContext string toolName = "azure", Dictionary? arguments = null) { - var mockServer = Substitute.For(); - return new ModelContextProtocol.Server.RequestContext(mockServer) + var mockServer = Substitute.For(); + return new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) { Params = new CallToolRequestParams { @@ -80,7 +80,7 @@ public async Task ListToolsHandler_ReturnsAzureToolWithExpectedSchema() var request = CreateListToolsRequest(); // Act - var result = await toolLoader.ListToolsHandler(request, CancellationToken.None); + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -103,11 +103,11 @@ public async Task ListToolsHandler_WithMockedDiscovery_ReturnsSingleAzureTool() var request = CreateListToolsRequest(); // Setup mock to return empty servers (SingleProxyToolLoader always returns the azure tool) - mockDiscoveryStrategy.DiscoverServersAsync() + mockDiscoveryStrategy.DiscoverServersAsync(TestContext.Current.CancellationToken) .Returns(Task.FromResult(Enumerable.Empty())); // Act - var result = await toolLoader.ListToolsHandler(request, CancellationToken.None); + var result = await toolLoader.ListToolsHandler(request, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -131,7 +131,7 @@ public async Task CallToolHandler_WithLearnMode_ReturnsRootToolsList() var request = CreateCallToolRequest("azure", arguments); // Act - var result = await toolLoader.CallToolHandler(request, CancellationToken.None); + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -161,7 +161,7 @@ public async Task CallToolHandler_WithToolLearnMode_ThrowsExceptionForUnknownToo // Act & Assert // The current implementation throws KeyNotFoundException for unknown tools await Assert.ThrowsAsync(async () => - await toolLoader.CallToolHandler(request, CancellationToken.None)); + await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken)); } [Fact] @@ -177,7 +177,7 @@ public async Task CallToolHandler_WithIntentOnly_AutoEnablesLearnMode() var request = CreateCallToolRequest("azure", arguments); // Act - var result = await toolLoader.CallToolHandler(request, CancellationToken.None); + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -205,7 +205,7 @@ public async Task CallToolHandler_WithMissingToolAndCommand_ReturnsGuidanceMessa var request = CreateCallToolRequest("azure", arguments); // Act - var result = await toolLoader.CallToolHandler(request, CancellationToken.None); + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -223,14 +223,14 @@ public async Task CallToolHandler_WithNullParams_ReturnsGuidanceMessage() { // Arrange var (toolLoader, _) = CreateToolLoader(useRealDiscovery: true); - var mockServer = Substitute.For(); - var request = new ModelContextProtocol.Server.RequestContext(mockServer) + var mockServer = Substitute.For(); + var request = new ModelContextProtocol.Server.RequestContext(mockServer, new() { Method = RequestMethods.ToolsCall }) { Params = null }; // Act - var result = await toolLoader.CallToolHandler(request, CancellationToken.None); + var result = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -254,8 +254,8 @@ public async Task SingleProxyToolLoader_CachesRootToolsJson() var request = CreateCallToolRequest("azure", arguments); // Act - Call twice to test caching - var result1 = await toolLoader.CallToolHandler(request, CancellationToken.None); - var result2 = await toolLoader.CallToolHandler(request, CancellationToken.None); + var result1 = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); + var result2 = await toolLoader.CallToolHandler(request, TestContext.Current.CancellationToken); // Assert - Both calls should succeed and return consistent results Assert.NotNull(result1); diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Helpers/MockMcpClientBuilder.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Helpers/MockMcpClientBuilder.cs index c2c9d8c6bb..542f98aa27 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Helpers/MockMcpClientBuilder.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Helpers/MockMcpClientBuilder.cs @@ -6,11 +6,12 @@ using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using NSubstitute; +using Xunit; namespace Azure.Mcp.Core.UnitTests.Areas.Server.Helpers; /// -/// A builder for creating mock instances for testing purposes. +/// A builder for creating mock instances for testing purposes. /// Provides a fluent API for registering mock tools with custom handlers. /// public sealed class MockMcpClientBuilder @@ -114,23 +115,23 @@ public MockMcpClientBuilder ClearTools() } /// - /// Builds and returns a mock instance configured with the registered tools. + /// Builds and returns a mock instance configured with the registered tools. /// - /// A mock instance. - public IMcpClient Build() + /// A mock instance. + public McpClient Build() { - var mockClient = Substitute.For(); + var mockClient = Substitute.For(); // Setup tools/list response mockClient.SendRequestAsync( Arg.Is(req => req.Method == "tools/list"), - Arg.Any()) + TestContext.Current.CancellationToken) .Returns(callInfo => HandleListToolsRequest(callInfo.Arg())); // Setup tools/call response mockClient.SendRequestAsync( Arg.Is(req => req.Method == "tools/call"), - Arg.Any()) + TestContext.Current.CancellationToken) .Returns(callInfo => HandleCallToolRequest(callInfo.Arg())); return mockClient; diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Helpers/MockMcpDiscoveryStrategyBuilder.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Helpers/MockMcpDiscoveryStrategyBuilder.cs index 746bbb6afc..bf173d996e 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Helpers/MockMcpDiscoveryStrategyBuilder.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Helpers/MockMcpDiscoveryStrategyBuilder.cs @@ -4,6 +4,7 @@ using Azure.Mcp.Core.Areas.Server.Commands.Discovery; using ModelContextProtocol.Client; using NSubstitute; +using Xunit; namespace Azure.Mcp.Core.UnitTests.Areas.Server.Helpers; @@ -30,7 +31,7 @@ public MockMcpDiscoveryStrategyBuilder() /// The description of the server. If null, uses a default description. /// The mock client to return for this server. /// The current instance for method chaining. - public MockMcpDiscoveryStrategyBuilder AddServer(string serverId, string? serverName = null, string? description = null, IMcpClient? client = null) + public MockMcpDiscoveryStrategyBuilder AddServer(string serverId, string? serverName = null, string? description = null, McpClient? client = null) { var mockProvider = Substitute.For(); var metadata = new McpServerMetadata @@ -44,13 +45,13 @@ public MockMcpDiscoveryStrategyBuilder AddServer(string serverId, string? server if (client != null) { - mockProvider.CreateClientAsync(Arg.Any()).Returns(Task.FromResult(client)); + mockProvider.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(client)); } else { // If no client is provided, create a basic substitute - var defaultClient = Substitute.For(); - mockProvider.CreateClientAsync(Arg.Any()).Returns(Task.FromResult(defaultClient)); + var defaultClient = Substitute.For(); + mockProvider.CreateClientAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(defaultClient)); } _providers.Add(mockProvider); @@ -121,10 +122,10 @@ public IMcpDiscoveryStrategy Build() var mockStrategy = Substitute.For(); // Configure DiscoverServersAsync to return the current providers - mockStrategy.DiscoverServersAsync().Returns(Task.FromResult>(_providers)); + mockStrategy.DiscoverServersAsync(Arg.Any()).Returns(Task.FromResult>(_providers)); // Configure FindServerProviderAsync to find providers by name (case-insensitive) - mockStrategy.FindServerProviderAsync(Arg.Any()).Returns(callInfo => + mockStrategy.FindServerProviderAsync(Arg.Any(), Arg.Any()).Returns(callInfo => { var serverName = callInfo.Arg(); var provider = _providers.FirstOrDefault(p => @@ -139,7 +140,7 @@ public IMcpDiscoveryStrategy Build() }); // Configure GetOrCreateClientAsync to return the appropriate client - mockStrategy.GetOrCreateClientAsync(Arg.Any(), Arg.Any()).Returns(callInfo => + mockStrategy.GetOrCreateClientAsync(Arg.Any(), Arg.Any(), Arg.Any()).Returns(callInfo => { var serverName = callInfo.Arg(); var clientOptions = callInfo.ArgAt(1) ?? new McpClientOptions(); @@ -153,7 +154,7 @@ public IMcpDiscoveryStrategy Build() } // Return the client from the provider - return provider.CreateClientAsync(clientOptions); + return provider.CreateClientAsync(clientOptions, TestContext.Current.CancellationToken); }); return mockStrategy; diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Models/RegistryRootTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Models/RegistryRootTests.cs index 48ee7de826..eaf0cfdf03 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Models/RegistryRootTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Models/RegistryRootTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Text.Json; +using Azure.Mcp.Core.Areas.Server; using Azure.Mcp.Core.Areas.Server.Models; using Xunit; @@ -9,23 +10,17 @@ namespace Azure.Mcp.Core.UnitTests.Areas.Server.Models; public class RegistryRootTests { - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - WriteIndented = true - }; - [Fact] public void RegistryRoot_SerializesToJson_WithEmptyServers() { // Arrange var registryRoot = new RegistryRoot { - Servers = new Dictionary() + Servers = [] }; // Act - var json = JsonSerializer.Serialize(registryRoot, JsonOptions); + var json = JsonSerializer.Serialize(registryRoot, ServerJsonContext.Default.RegistryRoot); // Assert Assert.Contains("\"servers\"", json); @@ -59,22 +54,22 @@ public void RegistryRoot_SerializesToJson_WithSingleServer() }; // Act - var json = JsonSerializer.Serialize(registryRoot, JsonOptions); + var json = JsonSerializer.Serialize(registryRoot, ServerJsonContext.Default.RegistryRoot); // Assert Assert.Contains("\"servers\"", json); Assert.Contains("\"test-server\"", json); - Assert.Contains("\"url\": \"https://example.com/mcp\"", json); - Assert.Contains("\"description\": \"Test MCP Server\"", json); - Assert.Contains("\"type\": \"stdio\"", json); - Assert.Contains("\"command\": \"node\"", json); + Assert.Contains("\"url\":\"https://example.com/mcp\"", json); + Assert.Contains("\"description\":\"Test MCP Server\"", json); + Assert.Contains("\"type\":\"stdio\"", json); + Assert.Contains("\"command\":\"node\"", json); Assert.Contains("\"args\"", json); Assert.Contains("\"server.js\"", json); Assert.Contains("\"--port\"", json); Assert.Contains("\"3000\"", json); Assert.Contains("\"env\"", json); - Assert.Contains("\"NODE_ENV\": \"production\"", json); - Assert.Contains("\"DEBUG\": \"true\"", json); + Assert.Contains("\"NODE_ENV\":\"production\"", json); + Assert.Contains("\"DEBUG\":\"true\"", json); } [Fact] @@ -100,13 +95,13 @@ public void RegistryRoot_DeserializesFromJson_WithSingleServer() """; // Act - var registryRoot = JsonSerializer.Deserialize(json, JsonOptions); + var registryRoot = JsonSerializer.Deserialize(json, ServerJsonContext.Default.RegistryRoot); // Assert Assert.NotNull(registryRoot); Assert.NotNull(registryRoot.Servers); Assert.Single(registryRoot.Servers); - Assert.True(registryRoot.Servers.ContainsKey("azure-mcp")); + Assert.Contains("azure-mcp", registryRoot.Servers); var serverInfo = registryRoot.Servers["azure-mcp"]; Assert.NotNull(serverInfo); @@ -149,14 +144,14 @@ public void RegistryRoot_DeserializesFromJson_WithMultipleServers() """; // Act - var registryRoot = JsonSerializer.Deserialize(json, JsonOptions); + var registryRoot = JsonSerializer.Deserialize(json, ServerJsonContext.Default.RegistryRoot); // Assert Assert.NotNull(registryRoot); Assert.NotNull(registryRoot.Servers); Assert.Equal(2, registryRoot.Servers.Count); - Assert.True(registryRoot.Servers.ContainsKey("azure-mcp")); - Assert.True(registryRoot.Servers.ContainsKey("local-server")); + Assert.Contains("azure-mcp", registryRoot.Servers); + Assert.Contains("local-server", registryRoot.Servers); var azureServer = registryRoot.Servers["azure-mcp"]; Assert.Equal("stdio", azureServer.Type); @@ -195,8 +190,8 @@ public void RegistryRoot_SerializesAndDeserializes_RoundTrip() }; // Act - var json = JsonSerializer.Serialize(originalRegistry, JsonOptions); - var deserializedRegistry = JsonSerializer.Deserialize(json, JsonOptions); + var json = JsonSerializer.Serialize(originalRegistry, ServerJsonContext.Default.RegistryRoot); + var deserializedRegistry = JsonSerializer.Deserialize(json, ServerJsonContext.Default.RegistryRoot); // Assert Assert.NotNull(deserializedRegistry); @@ -231,8 +226,8 @@ public void RegistryRoot_HandlesNullServers() var registryRoot = new RegistryRoot { Servers = null }; // Act - var json = JsonSerializer.Serialize(registryRoot, JsonOptions); - var deserialized = JsonSerializer.Deserialize(json, JsonOptions); + var json = JsonSerializer.Serialize(registryRoot, ServerJsonContext.Default.RegistryRoot); + var deserialized = JsonSerializer.Deserialize(json, ServerJsonContext.Default.RegistryRoot); // Assert Assert.NotNull(deserialized); @@ -251,13 +246,13 @@ public void RegistryServerInfo_IgnoresNamePropertyInJson() }; // Act - var json = JsonSerializer.Serialize(serverInfo, JsonOptions); + var json = JsonSerializer.Serialize(serverInfo, ServerJsonContext.Default.RegistryServerInfo); // Assert Assert.DoesNotContain("\"name\"", json); Assert.DoesNotContain("test-name", json); - Assert.Contains("\"url\": \"https://example.com\"", json); - Assert.Contains("\"description\": \"Test server\"", json); + Assert.Contains("\"url\":\"https://example.com\"", json); + Assert.Contains("\"description\":\"Test server\"", json); } [Fact] @@ -273,7 +268,7 @@ public void RegistryServerInfo_NamePropertyNotDeserializedFromJson() """; // Act - var serverInfo = JsonSerializer.Deserialize(json, JsonOptions); + var serverInfo = JsonSerializer.Deserialize(json, ServerJsonContext.Default.RegistryServerInfo); // Assert Assert.NotNull(serverInfo); diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/ServiceStartCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/ServiceStartCommandTests.cs index 369b6892cb..e03b2d95b7 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/ServiceStartCommandTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/ServiceStartCommandTests.cs @@ -2,12 +2,18 @@ // Licensed under the MIT License. using System.CommandLine; +using System.Diagnostics; using System.Net; using Azure.Mcp.Core.Areas.Server.Commands; using Azure.Mcp.Core.Areas.Server.Options; using Azure.Mcp.Core.Models.Command; +using Azure.Mcp.Core.Services.Telemetry; using Microsoft.Extensions.DependencyInjection; +using NSubstitute; using Xunit; +using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants; + +using TransportTypes = Azure.Mcp.Core.Areas.Server.Options.TransportTypes; namespace Azure.Mcp.Core.UnitTests.Areas.Server; @@ -88,10 +94,62 @@ public void AllOptionsRegistered_IncludesInsecureDisableElicitation() Assert.True(hasInsecureDisableElicitationOption, "InsecureDisableElicitation option should be registered"); } + [Fact] + public void AllOptionsRegistered_IncludesTool() + { + // Arrange & Act + var command = _command.GetCommand(); + + // Assert + var hasToolOption = command.Options.Any(o => + o.Name == ServiceOptionDefinitions.Tool.Name); + Assert.True(hasToolOption, "Tool option should be registered"); + } + + [Theory] + [InlineData("azmcp_storage_account_get")] + [InlineData("azmcp_keyvault_secret_get")] + [InlineData(null)] + public void ToolOption_ParsesCorrectly(string? expectedTool) + { + // Arrange + var parseResult = CreateParseResultWithTool(expectedTool != null ? [expectedTool] : null); + + // Act + var actualTools = parseResult.GetValue(ServiceOptionDefinitions.Tool); + + // Assert + if (expectedTool == null) + { + Assert.True(actualTools == null || actualTools.Length == 0); + } + else + { + Assert.NotNull(actualTools); + Assert.Single(actualTools); + Assert.Equal(expectedTool, actualTools[0]); + } + } + + [Fact] + public void ToolOption_ParsesMultipleToolsCorrectly() + { + // Arrange + var expectedTools = new[] { "azmcp_storage_account_get", "azmcp_keyvault_secret_get" }; + var parseResult = CreateParseResultWithTool(expectedTools); + + // Act + var actualTools = parseResult.GetValue(ServiceOptionDefinitions.Tool); + + // Assert + Assert.NotNull(actualTools); + Assert.Equal(expectedTools.Length, actualTools.Length); + Assert.Equal(expectedTools, actualTools); + } + [Theory] [InlineData("sse")] [InlineData("websocket")] - [InlineData("http")] [InlineData("invalid")] public async Task ExecuteAsync_InvalidTransport_ReturnsValidationError(string invalidTransport) { @@ -101,12 +159,12 @@ public async Task ExecuteAsync_InvalidTransport_ReturnsValidationError(string in var context = new CommandContext(serviceProvider); // Act - var response = await _command.ExecuteAsync(context, parseResult); + var response = await _command.ExecuteAsync(context, parseResult, TestContext.Current.CancellationToken); // Assert Assert.Equal(HttpStatusCode.BadRequest, response.Status); Assert.Contains($"Invalid transport '{invalidTransport}'", response.Message); - Assert.Contains("Valid transports are: stdio.", response.Message); + Assert.Contains("Valid transports are: stdio, http.", response.Message); } [Theory] @@ -121,12 +179,12 @@ public async Task ExecuteAsync_InvalidMode_ReturnsValidationError(string invalid var context = new CommandContext(serviceProvider); // Act - var response = await _command.ExecuteAsync(context, parseResult); + var response = await _command.ExecuteAsync(context, parseResult, TestContext.Current.CancellationToken); // Assert Assert.Equal(HttpStatusCode.BadRequest, response.Status); Assert.Contains($"Invalid mode '{invalidMode}'", response.Message); - Assert.Contains("Valid modes are: single, namespace, all.", response.Message); + Assert.Contains("Valid modes are: single, namespace, all, consolidated.", response.Message); } [Theory] @@ -142,7 +200,7 @@ public async Task ExecuteAsync_ValidMode_DoesNotReturnValidationError(string? va var context = new CommandContext(serviceProvider); // Act - var response = await _command.ExecuteAsync(context, parseResult); + var response = await _command.ExecuteAsync(context, parseResult, TestContext.Current.CancellationToken); // Assert - Should not fail validation, though may fail later due to server startup if (response.Status == HttpStatusCode.BadRequest && response.Message?.Contains("Invalid mode") == true) @@ -161,15 +219,50 @@ public void BindOptions_WithAllOptions_ReturnsCorrectlyConfiguredOptions() var options = GetBoundOptions(parseResult); // Assert - Assert.Equal("stdio", options.Transport); + Assert.Equal(TransportTypes.StdIo, options.Transport); Assert.Equal(new[] { "storage", "keyvault" }, options.Namespace); Assert.Equal("all", options.Mode); Assert.True(options.ReadOnly); Assert.True(options.Debug); - Assert.False(options.EnableInsecureTransports); + Assert.False(options.DangerouslyDisableHttpIncomingAuth); Assert.True(options.InsecureDisableElicitation); } + [Fact] + public void BindOptions_WithTool_ReturnsCorrectlyConfiguredOptions() + { + // Arrange + var expectedTool = "azmcp_group_list"; + var parseResult = CreateParseResultWithTool([expectedTool]); + + // Act + var options = GetBoundOptions(parseResult); + + // Assert + Assert.NotNull(options.Tool); + Assert.Single(options.Tool); + Assert.Equal(expectedTool, options.Tool[0]); + Assert.Equal(TransportTypes.StdIo, options.Transport); + Assert.Equal("all", options.Mode); + } + + [Fact] + public void BindOptions_WithMultipleToolsAndExplicitMode_OverridesToAllMode() + { + // Arrange - Explicitly set mode to single but also provide multiple tools + var tools = new[] { "azmcp_group_list", "azmcp_subscription_list" }; + var parseResult = CreateParseResultWithToolsAndMode(tools, "single"); + + // Act + var options = GetBoundOptions(parseResult); + + // Assert + Assert.NotNull(options.Tool); + Assert.Equal(2, options.Tool.Length); + Assert.Equal(tools, options.Tool); + Assert.Equal("all", options.Mode); + } + [Fact] public void BindOptions_WithDefaults_ReturnsDefaultValues() { @@ -180,12 +273,12 @@ public void BindOptions_WithDefaults_ReturnsDefaultValues() var options = GetBoundOptions(parseResult); // Assert - Assert.Equal("stdio", options.Transport); // Default transport + Assert.Equal(TransportTypes.StdIo, options.Transport); // Default transport Assert.Null(options.Namespace); Assert.Equal("namespace", options.Mode); // Default mode Assert.False(options.ReadOnly); // Default readonly Assert.False(options.Debug); - Assert.False(options.EnableInsecureTransports); + Assert.False(options.DangerouslyDisableHttpIncomingAuth); Assert.False(options.InsecureDisableElicitation); } @@ -201,7 +294,7 @@ public void Validate_WithValidOptions_ReturnsValidResult() // Assert Assert.True(result.IsValid); - Assert.Null(result.ErrorMessage); + Assert.Empty(result.Errors); } [Fact] @@ -216,7 +309,7 @@ public void Validate_WithInvalidTransport_ReturnsInvalidResult() // Assert Assert.False(result.IsValid); - Assert.Contains("Invalid transport 'invalid'", result.ErrorMessage); + Assert.Contains("Invalid transport 'invalid'", string.Join('\n', result.Errors)); } [Fact] @@ -231,7 +324,38 @@ public void Validate_WithInvalidMode_ReturnsInvalidResult() // Assert Assert.False(result.IsValid); - Assert.Contains("Invalid mode 'invalid'", result.ErrorMessage); + Assert.Contains("Invalid mode 'invalid'", string.Join('\n', result.Errors)); + } + + [Fact] + public void Validate_WithNamespaceAndTool_ReturnsInvalidResult() + { + // Arrange + var parseResult = CreateParseResultWithNamespaceAndTool(); + var commandResult = parseResult.CommandResult; + + // Act + var result = _command.Validate(commandResult, null); + + // Assert + Assert.False(result.IsValid); + Assert.Contains("--namespace and --tool options cannot be used together", string.Join('\n', result.Errors)); + } + + [Fact] + public async Task ExecuteAsync_WithNamespaceAndTool_ReturnsValidationError() + { + // Arrange + var parseResult = CreateParseResultWithNamespaceAndTool(); + var serviceProvider = new ServiceCollection().BuildServiceProvider(); + var context = new CommandContext(serviceProvider); + + // Act + var response = await _command.ExecuteAsync(context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + Assert.Contains("--namespace and --tool options cannot be used together", response.Message); } [Fact] @@ -263,17 +387,31 @@ public void GetErrorMessage_WithModeArgumentException_ReturnsCustomMessage() } [Fact] - public void GetErrorMessage_WithInsecureTransportException_ReturnsCustomMessage() + public void GetErrorMessage_WithDangerouslyDisableHttpIncomingAuthException_ReturnsCustomMessage() { // Arrange - var exception = new InvalidOperationException("Using --enable-insecure-transport requires..."); + var exception = new InvalidOperationException("Using --dangerously-disable-http-incoming-auth requires..."); // Act var message = GetErrorMessage(exception); // Assert - Assert.Contains("Insecure transport configuration error", message); - Assert.Contains("proper authentication configured", message); + Assert.Contains("Configuration error to disable incoming HTTP authentication", message); + Assert.Contains("proper authentication is configured", message); + } + + [Fact] + public void GetErrorMessage_WithNamespaceAndToolException_ReturnsCustomMessage() + { + // Arrange + var exception = new ArgumentException("--namespace and --tool options cannot be used together"); + + // Act + var message = GetErrorMessage(exception); + + // Assert + Assert.Contains("Configuration error", message); + Assert.Contains("mutually exclusive", message); } [Fact] @@ -326,7 +464,7 @@ public async Task ExecuteAsync_ValidTransport_DoesNotThrow() // Act & Assert - Check that ArgumentException is not thrown for valid transport try { - await _command.ExecuteAsync(context, parseResult); + await _command.ExecuteAsync(context, parseResult, TestContext.Current.CancellationToken); } catch (ArgumentException ex) when (ex.Message.Contains("transport")) { @@ -350,7 +488,7 @@ public async Task ExecuteAsync_OmittedTransport_UsesDefaultAndDoesNotThrow() // Act & Assert - Check that ArgumentException is not thrown when transport is omitted try { - await _command.ExecuteAsync(context, parseResult); + await _command.ExecuteAsync(context, parseResult, TestContext.Current.CancellationToken); } catch (ArgumentException ex) when (ex.Message.Contains("transport")) { @@ -363,6 +501,107 @@ public async Task ExecuteAsync_OmittedTransport_UsesDefaultAndDoesNotThrow() } } + + [Fact] + public void InitializedHandler_SetsStartupInformation() + { + // Arrange + var serviceStartOptions = new ServiceStartOptions + { + Transport = TransportTypes.StdIo, + Mode = "test-mode", + Tool = ["test-tool1", "test-tool2"], + ReadOnly = false, + Debug = true, + Namespace = ["storage", "keyvault"], + InsecureDisableElicitation = false, + DangerouslyDisableHttpIncomingAuth = true, + }; + var activity = new Activity("test-activity"); + var mockTelemetry = Substitute.For(); + mockTelemetry.StartActivity(Arg.Any()).Returns(activity); + + + // Act + ServiceStartCommand.LogStartTelemetry(mockTelemetry, serviceStartOptions); + + // Assert + mockTelemetry.Received(1).StartActivity(ActivityName.ServerStarted); + + var dangerouslyDisableHttpIncomingAuth = GetAndAssertTagKeyValue(activity, TagName.DangerouslyDisableHttpIncomingAuth); + Assert.Equal(serviceStartOptions.DangerouslyDisableHttpIncomingAuth, dangerouslyDisableHttpIncomingAuth); + + var insecureDisableElicitation = GetAndAssertTagKeyValue(activity, TagName.InsecureDisableElicitation); + Assert.Equal(serviceStartOptions.InsecureDisableElicitation, insecureDisableElicitation); + + var transport = GetAndAssertTagKeyValue(activity, TagName.Transport); + Assert.Equal(serviceStartOptions.Transport, transport); + + var mode = GetAndAssertTagKeyValue(activity, TagName.ServerMode); + Assert.Equal(serviceStartOptions.Mode, mode); + + var tool = GetAndAssertTagKeyValue(activity, TagName.Tool); + Assert.Equal(string.Join(",", serviceStartOptions.Tool), tool); + + var readOnly = GetAndAssertTagKeyValue(activity, TagName.IsReadOnly); + Assert.Equal(serviceStartOptions.ReadOnly, readOnly); + + var debug = GetAndAssertTagKeyValue(activity, TagName.IsDebug); + Assert.Equal(serviceStartOptions.Debug, debug); + + var namespaces = GetAndAssertTagKeyValue(activity, TagName.Namespace); + Assert.Equal(string.Join(",", serviceStartOptions.Namespace), namespaces); + } + + [Fact] + public void InitializedHandler_SetsCorrectInformationWhenNull() + { + // Arrange + // Tool, Mode, and Namespace are null + var serviceStartOptions = new ServiceStartOptions + { + Transport = Core.Areas.Server.Options.TransportTypes.StdIo, + Mode = null, + ReadOnly = true, + Debug = false, + InsecureDisableElicitation = true, + DangerouslyDisableHttpIncomingAuth = false, + }; + var activity = new Activity("test-activity"); + var mockTelemetry = Substitute.For(); + mockTelemetry.StartActivity(Arg.Any()).Returns(activity); + + + // Act + ServiceStartCommand.LogStartTelemetry(mockTelemetry, serviceStartOptions); + + + + // Assert + mockTelemetry.Received(1).StartActivity(ActivityName.ServerStarted); + + var dangerouslyDisableHttpIncomingAuth = GetAndAssertTagKeyValue(activity, TagName.DangerouslyDisableHttpIncomingAuth); + Assert.Equal(serviceStartOptions.DangerouslyDisableHttpIncomingAuth, dangerouslyDisableHttpIncomingAuth); + + var insecureDisableElicitation = GetAndAssertTagKeyValue(activity, TagName.InsecureDisableElicitation); + Assert.Equal(serviceStartOptions.InsecureDisableElicitation, insecureDisableElicitation); + + var transport = GetAndAssertTagKeyValue(activity, TagName.Transport); + Assert.Equal(serviceStartOptions.Transport, transport); + + Assert.DoesNotContain(TagName.ServerMode, activity.TagObjects.Select(x => x.Key)); + + Assert.DoesNotContain(TagName.Tool, activity.TagObjects.Select(x => x.Key)); + + var readOnly = GetAndAssertTagKeyValue(activity, TagName.IsReadOnly); + Assert.Equal(serviceStartOptions.ReadOnly, readOnly); + + var debug = GetAndAssertTagKeyValue(activity, TagName.IsDebug); + Assert.Equal(serviceStartOptions.Debug, debug); + + Assert.DoesNotContain(TagName.Namespace, activity.TagObjects.Select(x => x.Key)); + } + private static ParseResult CreateParseResult(string? serviceValue) { var root = new RootCommand @@ -383,14 +622,8 @@ private static ParseResult CreateParseResult(string? serviceValue) return root.Parse([.. args]); } - private static ParseResult CreateParseResultWithInsecureDisableElicitation(bool insecureDisableElicitation) + private ParseResult CreateParseResultWithInsecureDisableElicitation(bool insecureDisableElicitation) { - var root = new RootCommand - { - ServiceOptionDefinitions.Namespace, - ServiceOptionDefinitions.Transport, - ServiceOptionDefinitions.InsecureDisableElicitation - }; var args = new List { "--transport", @@ -402,21 +635,11 @@ private static ParseResult CreateParseResultWithInsecureDisableElicitation(bool args.Add("--insecure-disable-elicitation"); } - return root.Parse([.. args]); + return _command.GetCommand().Parse([.. args]); } - private static ParseResult CreateParseResultWithTransport(string transport) + private ParseResult CreateParseResultWithTransport(string transport) { - var root = new RootCommand - { - ServiceOptionDefinitions.Namespace, - ServiceOptionDefinitions.Transport, - ServiceOptionDefinitions.Mode, - ServiceOptionDefinitions.ReadOnly, - ServiceOptionDefinitions.Debug, - ServiceOptionDefinitions.EnableInsecureTransports, - ServiceOptionDefinitions.InsecureDisableElicitation - }; var args = new List { "--transport", @@ -426,21 +649,11 @@ private static ParseResult CreateParseResultWithTransport(string transport) "--read-only" }; - return root.Parse([.. args]); + return _command.GetCommand().Parse([.. args]); } - private static ParseResult CreateParseResultWithoutTransport() + private ParseResult CreateParseResultWithoutTransport() { - var root = new RootCommand - { - ServiceOptionDefinitions.Namespace, - ServiceOptionDefinitions.Transport, - ServiceOptionDefinitions.Mode, - ServiceOptionDefinitions.ReadOnly, - ServiceOptionDefinitions.Debug, - ServiceOptionDefinitions.EnableInsecureTransports, - ServiceOptionDefinitions.InsecureDisableElicitation - }; var args = new List { "--mode", @@ -448,21 +661,11 @@ private static ParseResult CreateParseResultWithoutTransport() "--read-only" }; - return root.Parse([.. args]); + return _command.GetCommand().Parse([.. args]); } - private static ParseResult CreateParseResultWithMode(string? mode) + private ParseResult CreateParseResultWithMode(string? mode) { - var root = new RootCommand - { - ServiceOptionDefinitions.Namespace, - ServiceOptionDefinitions.Transport, - ServiceOptionDefinitions.Mode, - ServiceOptionDefinitions.ReadOnly, - ServiceOptionDefinitions.Debug, - ServiceOptionDefinitions.EnableInsecureTransports, - ServiceOptionDefinitions.InsecureDisableElicitation - }; var args = new List { "--transport", @@ -475,21 +678,11 @@ private static ParseResult CreateParseResultWithMode(string? mode) args.Add(mode); } - return root.Parse([.. args]); + return _command.GetCommand().Parse([.. args]); } - private static ParseResult CreateParseResultWithAllOptions() + private ParseResult CreateParseResultWithAllOptions() { - var root = new RootCommand - { - ServiceOptionDefinitions.Namespace, - ServiceOptionDefinitions.Transport, - ServiceOptionDefinitions.Mode, - ServiceOptionDefinitions.ReadOnly, - ServiceOptionDefinitions.Debug, - ServiceOptionDefinitions.EnableInsecureTransports, - ServiceOptionDefinitions.InsecureDisableElicitation - }; var args = new List { "--transport", "stdio", @@ -501,24 +694,60 @@ private static ParseResult CreateParseResultWithAllOptions() "--insecure-disable-elicitation" }; - return root.Parse([.. args]); + return _command.GetCommand().Parse([.. args]); } - private static ParseResult CreateParseResultWithMinimalOptions() + private ParseResult CreateParseResultWithTool(string[]? tools) { - var root = new RootCommand + var args = new List { - ServiceOptionDefinitions.Namespace, - ServiceOptionDefinitions.Transport, - ServiceOptionDefinitions.Mode, - ServiceOptionDefinitions.ReadOnly, - ServiceOptionDefinitions.Debug, - ServiceOptionDefinitions.EnableInsecureTransports, - ServiceOptionDefinitions.InsecureDisableElicitation + "--transport", "stdio" }; - var args = new List(); - return root.Parse([.. args]); + if (tools is not null) + { + foreach (var tool in tools) + { + args.Add("--tool"); + args.Add(tool); + } + } + + return _command.GetCommand().Parse([.. args]); + } + + private ParseResult CreateParseResultWithMinimalOptions() + { + return _command.GetCommand().Parse([]); + } + + private ParseResult CreateParseResultWithToolsAndMode(string[] tools, string mode) + { + var args = new List + { + "--transport", "stdio", + "--mode", mode + }; + + foreach (var tool in tools) + { + args.Add("--tool"); + args.Add(tool); + } + + return _command.GetCommand().Parse([.. args]); + } + + private ParseResult CreateParseResultWithNamespaceAndTool() + { + var args = new List + { + "--transport", "stdio", + "--namespace", "storage", + "--tool", "azmcp_storage_account_get" + }; + + return _command.GetCommand().Parse([.. args]); } private ServiceStartOptions GetBoundOptions(ParseResult parseResult) @@ -544,4 +773,14 @@ private HttpStatusCode GetStatusCode(Exception exception) System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); return (HttpStatusCode)method!.Invoke(_command, [exception])!; } + + private static object GetAndAssertTagKeyValue(Activity activity, string tagName) + { + var matching = activity.TagObjects.SingleOrDefault(x => string.Equals(x.Key, tagName, StringComparison.OrdinalIgnoreCase)); + + Assert.False(matching.Equals(default(KeyValuePair)), $"Tag '{tagName}' was not found in activity tags."); + Assert.NotNull(matching.Value); + + return matching.Value; + } } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionCommandTests.cs index b0ae794e68..bfcf5dd3e4 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionCommandTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionCommandTests.cs @@ -14,7 +14,6 @@ namespace Azure.Mcp.Core.UnitTests.Areas.Subscription; -[Trait("Area", "Core")] public class SubscriptionCommandTests { private readonly IServiceProvider _serviceProvider; @@ -41,117 +40,92 @@ public SubscriptionCommandTests() public void Validate_WithEnvironmentVariableOnly_PassesValidation() { // Arrange - var originalValue = EnvironmentHelpers.GetAzureSubscriptionId(); EnvironmentHelpers.SetAzureSubscriptionId("env-subs"); - try - { - var parseResult = _commandDefinition.Parse([]); - // Act & Assert - Assert.Empty(parseResult.Errors); - } - finally - { - // Cleanup - EnvironmentHelpers.SetAzureSubscriptionId(originalValue); - } + // Act + var parseResult = _commandDefinition.Parse([]); + + // Assert + Assert.Empty(parseResult.Errors); } [Fact] public async Task ExecuteAsync_WithEnvironmentVariableOnly_CallsServiceWithCorrectSubscription() { // Arrange - var originalValue = EnvironmentHelpers.GetAzureSubscriptionId(); EnvironmentHelpers.SetAzureSubscriptionId("env-subs"); - try + var expectedAccounts = new List { - var expectedAccounts = new List - { - new("account1", null, null, null, null, null, null, null, null, null), - new("account2", null, null, null, null, null, null, null, null, null) - }; - - _storageService.GetAccountDetails( - Arg.Is(s => string.IsNullOrEmpty(s)), - Arg.Is("env-subs"), - Arg.Any(), - Arg.Any(), - Arg.Any()) - .Returns(Task.FromResult(expectedAccounts)); - - var parseResult = _commandDefinition.Parse([]); - - // Act - var response = await _command.ExecuteAsync(_context, parseResult); - - // Assert - Assert.NotNull(response); - - // Verify the service was called with the environment variable subscription - _ = _storageService.Received(1).GetAccountDetails( - Arg.Is(s => string.IsNullOrEmpty(s)), - "env-subs", - Arg.Any(), - Arg.Any(), - Arg.Any()); - } - finally - { - // Cleanup - EnvironmentHelpers.SetAzureSubscriptionId(originalValue); - } + new("account1", null, null, null, null, null, null, null, null, null), + new("account2", null, null, null, null, null, null, null, null, null) + }; + + _storageService.GetAccountDetails( + Arg.Is(s => string.IsNullOrEmpty(s)), + Arg.Is("env-subs"), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(expectedAccounts)); + + var parseResult = _commandDefinition.Parse([]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + + // Verify the service was called with the environment variable subscription + _ = _storageService.Received(1).GetAccountDetails( + Arg.Is(s => string.IsNullOrEmpty(s)), + "env-subs", + Arg.Any(), + Arg.Any(), + Arg.Any()); } [Fact] public async Task ExecuteAsync_WithBothOptionAndEnvironmentVariable_PrefersOption() { // Arrange - var originalValue = EnvironmentHelpers.GetAzureSubscriptionId(); EnvironmentHelpers.SetAzureSubscriptionId("env-subs"); - try - { - var expectedAccounts = new List - { - new("account1", null, null, null, null, null, null, null, null, null), - new("account2", null, null, null, null, null, null, null, null, null) - }; - - _storageService.GetAccountDetails( - Arg.Is(s => string.IsNullOrEmpty(s)), - Arg.Is("option-subs"), - Arg.Any(), - Arg.Any(), - Arg.Any()) - .Returns(Task.FromResult(expectedAccounts)); - - var parseResult = _commandDefinition.Parse(["--subscription", "option-subs"]); - - // Act - var response = await _command.ExecuteAsync(_context, parseResult); - - // Assert - Assert.NotNull(response); - - // Verify the service was called with the option subscription, not the environment variable - _ = _storageService.Received(1).GetAccountDetails( - Arg.Is(s => string.IsNullOrEmpty(s)), - "option-subs", - Arg.Any(), - Arg.Any(), - Arg.Any()); - _ = _storageService.DidNotReceive().GetAccountDetails( - Arg.Is(s => string.IsNullOrEmpty(s)), - "env-subs", - Arg.Any(), - Arg.Any(), - Arg.Any()); - } - finally + var expectedAccounts = new List { - // Cleanup - EnvironmentHelpers.SetAzureSubscriptionId(originalValue); - } + new("account1", null, null, null, null, null, null, null, null, null), + new("account2", null, null, null, null, null, null, null, null, null) + }; + + _storageService.GetAccountDetails( + Arg.Is(s => string.IsNullOrEmpty(s)), + Arg.Is("option-subs"), + Arg.Any(), + Arg.Any(), + Arg.Any()) + .Returns(Task.FromResult(expectedAccounts)); + + var parseResult = _commandDefinition.Parse(["--subscription", "option-subs"]); + + // Act + var response = await _command.ExecuteAsync(_context, parseResult, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + + // Verify the service was called with the option subscription, not the environment variable + _ = _storageService.Received(1).GetAccountDetails( + Arg.Is(s => string.IsNullOrEmpty(s)), + "option-subs", + Arg.Any(), + Arg.Any(), + Arg.Any()); + _ = _storageService.DidNotReceive().GetAccountDetails( + Arg.Is(s => string.IsNullOrEmpty(s)), + "env-subs", + Arg.Any(), + Arg.Any(), + Arg.Any()); } } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionListCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionListCommandTests.cs index dcc985d9ac..dc4f537c44 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionListCommandTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Subscription/SubscriptionListCommandTests.cs @@ -21,7 +21,7 @@ namespace Azure.Mcp.Core.UnitTests.Areas.Subscription; public class SubscriptionListCommandTests { private readonly IServiceProvider _serviceProvider; - private readonly IMcpServer _mcpServer; + private readonly McpServer _mcpServer; private readonly ILogger _logger; private readonly ISubscriptionService _subscriptionService; private readonly SubscriptionListCommand _command; @@ -30,7 +30,7 @@ public class SubscriptionListCommandTests public SubscriptionListCommandTests() { - _mcpServer = Substitute.For(); + _mcpServer = Substitute.For(); _subscriptionService = Substitute.For(); _logger = Substitute.For>(); var collection = new ServiceCollection() @@ -54,13 +54,13 @@ public async Task ExecuteAsync_NoParameters_ReturnsSubscriptions() }; _subscriptionService - .GetSubscriptions(Arg.Any(), Arg.Any()) + .GetSubscriptions(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(expectedSubscriptions); var args = _commandDefinition.Parse(""); // Act - var result = await _command.ExecuteAsync(_context, args); + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -80,7 +80,7 @@ public async Task ExecuteAsync_NoParameters_ReturnsSubscriptions() Assert.Equal("sub2", second.GetProperty("subscriptionId").GetString()); Assert.Equal("Subscription 2", second.GetProperty("displayName").GetString()); - await _subscriptionService.Received(1).GetSubscriptions(Arg.Any(), Arg.Any()); + await _subscriptionService.Received(1).GetSubscriptions(Arg.Any(), Arg.Any(), Arg.Any()); } [Fact] @@ -91,18 +91,19 @@ public async Task ExecuteAsync_WithTenantId_PassesTenantToService() var args = _commandDefinition.Parse($"--tenant {tenantId}"); _subscriptionService - .GetSubscriptions(Arg.Is(x => x == tenantId), Arg.Any()) + .GetSubscriptions(Arg.Is(x => x == tenantId), Arg.Any(), Arg.Any()) .Returns([SubscriptionTestHelpers.CreateSubscriptionData("sub1", "Sub1")]); // Act - var result = await _command.ExecuteAsync(_context, args); + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); Assert.Equal(HttpStatusCode.OK, result.Status); await _subscriptionService.Received(1).GetSubscriptions( Arg.Is(x => x == tenantId), - Arg.Any()); + Arg.Any(), + Arg.Any()); } [Fact] @@ -110,13 +111,13 @@ public async Task ExecuteAsync_EmptySubscriptionList_ReturnsNotNullResults() { // Arrange _subscriptionService - .GetSubscriptions(Arg.Any(), Arg.Any()) + .GetSubscriptions(Arg.Any(), Arg.Any(), Arg.Any()) .Returns([]); var args = _commandDefinition.Parse(""); // Act - var result = await _command.ExecuteAsync(_context, args); + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -130,13 +131,13 @@ public async Task ExecuteAsync_ServiceThrowsException_ReturnsErrorInResponse() // Arrange var expectedError = "Test error message"; _subscriptionService - .GetSubscriptions(Arg.Any(), Arg.Any()) + .GetSubscriptions(Arg.Any(), Arg.Any(), Arg.Any()) .Returns(Task.FromException>(new Exception(expectedError))); var args = _commandDefinition.Parse(""); // Act - var result = await _command.ExecuteAsync(_context, args); + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); @@ -152,18 +153,19 @@ public async Task ExecuteAsync_WithAuthMethod_PassesAuthMethodToCommand() var args = _commandDefinition.Parse($"--auth-method {authMethod}"); _subscriptionService - .GetSubscriptions(Arg.Any(), Arg.Any()) + .GetSubscriptions(Arg.Any(), Arg.Any(), Arg.Any()) .Returns([SubscriptionTestHelpers.CreateSubscriptionData("sub1", "Sub1")]); // Act - var result = await _command.ExecuteAsync(_context, args); + var result = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); // Assert Assert.NotNull(result); Assert.Equal(HttpStatusCode.OK, result.Status); await _subscriptionService.Received(1).GetSubscriptions( Arg.Any(), - Arg.Any()); + Arg.Any(), + Arg.Any()); } } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListCommandTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListCommandTests.cs index ec1bd73d0d..a6f1787a05 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListCommandTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Tools/UnitTests/ToolsListCommandTests.cs @@ -2,16 +2,21 @@ // Licensed under the MIT License. using System.CommandLine; +using System.CommandLine.Parsing; using System.Net; using System.Text.Json; using Azure.Mcp.Core.Areas; using Azure.Mcp.Core.Areas.Tools.Commands; +using Azure.Mcp.Core.Areas.Tools.Options; using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Configuration; +using Azure.Mcp.Core.Extensions; using Azure.Mcp.Core.Models.Command; using Azure.Mcp.Core.Services.Telemetry; using Azure.Mcp.Core.UnitTests.Areas.Server; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using NSubstitute; using Xunit; @@ -51,6 +56,19 @@ private static List DeserializeResults(object results) return JsonSerializer.Deserialize>(json) ?? new List(); } + /// + /// Helper method to deserialize response results to ToolNamesResult + /// + private static ToolsListCommand.ToolNamesResult DeserializeToolNamesResult(object results) + { + var json = JsonSerializer.Serialize(results); + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + return JsonSerializer.Deserialize(json, options) ?? new ToolsListCommand.ToolNamesResult(new List()); + } + /// /// Verifies that the command returns a valid list of CommandInfo objects /// when executed with a properly configured context. @@ -63,7 +81,7 @@ public async Task ExecuteAsync_WithValidContext_ReturnsCommandInfoList() var args = _commandDefinition.Parse([]); // Act - var response = await _command.ExecuteAsync(_context, args); + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); // Assert Assert.NotNull(response); @@ -80,7 +98,7 @@ public async Task ExecuteAsync_WithValidContext_ReturnsCommandInfoList() Assert.False(string.IsNullOrWhiteSpace(command.Description), "Command description should not be empty"); Assert.False(string.IsNullOrWhiteSpace(command.Command), "Command path should not be empty"); - Assert.StartsWith("azmcp ", command.Command); + Assert.False(command.Command.StartsWith("azmcp ")); if (command.Options != null && command.Options.Count > 0) { @@ -104,7 +122,7 @@ public async Task ExecuteAsync_JsonSerializationStressTest_HandlesLargeResults() var args = _commandDefinition.Parse([]); // Act - var response = await _command.ExecuteAsync(_context, args); + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); // Assert Assert.NotNull(response); @@ -131,7 +149,7 @@ public async Task ExecuteAsync_WithValidContext_FiltersHiddenCommands() var args = _commandDefinition.Parse([]); // Act - var response = await _command.ExecuteAsync(_context, args); + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); // Assert Assert.NotNull(response); @@ -144,7 +162,6 @@ public async Task ExecuteAsync_WithValidContext_FiltersHiddenCommands() Assert.DoesNotContain(result, cmd => cmd.Name == "list" && cmd.Command.Contains("tool")); Assert.Contains(result, cmd => !string.IsNullOrEmpty(cmd.Name)); - } /// @@ -158,7 +175,7 @@ public async Task ExecuteAsync_WithValidContext_IncludesOptionsForCommands() var args = _commandDefinition.Parse([]); // Act - var response = await _command.ExecuteAsync(_context, args); + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); // Assert Assert.NotNull(response); @@ -190,7 +207,7 @@ public async Task ExecuteAsync_WithNullServiceProvider_HandlesGracefully() var args = _commandDefinition.Parse([]); // Act - var response = await _command.ExecuteAsync(faultyContext, args); + var response = await _command.ExecuteAsync(faultyContext, args, TestContext.Current.CancellationToken); // Assert Assert.NotNull(response); @@ -214,7 +231,7 @@ public async Task ExecuteAsync_WithCorruptedCommandFactory_HandlesGracefully() var args = _commandDefinition.Parse([]); // Act - var response = await _command.ExecuteAsync(faultyContext, args); + var response = await _command.ExecuteAsync(faultyContext, args, TestContext.Current.CancellationToken); // Assert Assert.NotNull(response); @@ -233,7 +250,7 @@ public async Task ExecuteAsync_ReturnsSpecificKnownCommands() var args = _commandDefinition.Parse([]); // Act - var response = await _command.ExecuteAsync(_context, args); + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); // Assert Assert.NotNull(response); @@ -265,10 +282,10 @@ public async Task ExecuteAsync_ReturnsSpecificKnownCommands() Assert.True(appConfigCommands.Count > 0, $"Expected appconfig commands. All commands: {string.Join(", ", allCommands)}"); // Verify specific known commands exist - Assert.Contains(result, cmd => cmd.Command == "azmcp subscription list"); - Assert.Contains(result, cmd => cmd.Command == "azmcp keyvault key list"); - Assert.Contains(result, cmd => cmd.Command == "azmcp storage account get"); - Assert.Contains(result, cmd => cmd.Command == "azmcp appconfig account list"); + Assert.Contains(result, cmd => cmd.Command == "subscription list"); + Assert.Contains(result, cmd => cmd.Command == "keyvault key list"); + Assert.Contains(result, cmd => cmd.Command == "storage account get"); + Assert.Contains(result, cmd => cmd.Command == "appconfig account list"); // Verify that each command has proper structure foreach (var cmd in result.Take(4)) @@ -290,7 +307,7 @@ public async Task ExecuteAsync_CommandPathFormattingIsCorrect() var args = _commandDefinition.Parse([]); // Act - var response = await _command.ExecuteAsync(_context, args); + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); // Assert Assert.NotNull(response); @@ -311,6 +328,60 @@ public async Task ExecuteAsync_CommandPathFormattingIsCorrect() } } + /// + /// Verifies that the --namespace-mode switch returns only distinct top-level namespaces. + /// + [Fact] + public async Task ExecuteAsync_WithNamespaceSwitch_ReturnsNamespacesOnly() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--namespace-mode" }); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + + // Serialize then deserialize as list of CommandInfo + var json = JsonSerializer.Serialize(response.Results); + var namespaces = JsonSerializer.Deserialize>(json); + + Assert.NotNull(namespaces); + Assert.NotEmpty(namespaces); + + // Should include some well-known namespaces (matching Name property) + Assert.Contains(namespaces, ci => ci.Name.Equals("subscription", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(namespaces, ci => ci.Name.Equals("storage", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(namespaces, ci => ci.Name.Equals("keyvault", StringComparison.OrdinalIgnoreCase)); + + foreach (var ns in namespaces!) + { + Assert.False(string.IsNullOrWhiteSpace(ns.Name)); + Assert.False(string.IsNullOrWhiteSpace(ns.Command)); + + // For regular namespaces, Command equals Name + // For surfaced extension commands like "azqr", Command is "extension azqr" but Name is "azqr" + if (!ns.Command.Contains(' ')) + { + // Regular namespace: Command == Name + Assert.Equal(ns.Name, ns.Command); + } + else + { + // Surfaced extension command: Command is "{namespace} {commandName}", Name is just "{commandName}" + // When Azure MCP presents the commands as tools, the spaces in the commands are replaced by underscore + Assert.EndsWith(ns.Name, ns.Command.Replace(" ", "_")); + } + + Assert.Equal(ns.Name, ns.Name.Trim()); + Assert.DoesNotContain(" ", ns.Name); + // Namespace should not itself have options + Assert.Null(ns.Options); + } + } + /// /// Verifies that the command handles empty command factory gracefully /// and returns empty results when no commands are available. @@ -327,12 +398,19 @@ public async Task ExecuteAsync_WithEmptyCommandFactory_ReturnsEmptyResults() var logger = tempServiceProvider.GetRequiredService>(); var telemetryService = Substitute.For(); var emptyAreaSetups = Array.Empty(); + var configurationOptions = Microsoft.Extensions.Options.Options.Create(new AzureMcpServerConfiguration + { + Name = "Test Server", + Version = "Test Version", + DisplayName = "Test Display", + RootCommandGroupName = "azmcp" + }); // Create a NEW service collection just for the empty command factory var finalCollection = new ServiceCollection(); finalCollection.AddLogging(); - var emptyCommandFactory = new CommandFactory(tempServiceProvider, emptyAreaSetups, telemetryService, logger); + var emptyCommandFactory = new CommandFactory(tempServiceProvider, emptyAreaSetups, telemetryService, configurationOptions, logger); finalCollection.AddSingleton(emptyCommandFactory); var emptyServiceProvider = finalCollection.BuildServiceProvider(); @@ -340,7 +418,7 @@ public async Task ExecuteAsync_WithEmptyCommandFactory_ReturnsEmptyResults() var args = _commandDefinition.Parse([]); // Act - var response = await _command.ExecuteAsync(emptyContext, args); + var response = await _command.ExecuteAsync(emptyContext, args, TestContext.Current.CancellationToken); // Assert Assert.NotNull(response); @@ -367,4 +445,426 @@ public void Metadata_IndicatesNonDestructiveAndReadOnly() Assert.True(metadata.ReadOnly, "Tool list command should be read-only"); } + /// + /// Verifies that the command includes metadata for each tool in the output. + /// + [Fact] + public async Task ExecuteAsync_IncludesMetadataForAllCommands() + { + // Arrange + var args = _commandDefinition.Parse([]); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.NotNull(response.Results); + + var result = DeserializeResults(response.Results); + Assert.NotNull(result); + Assert.NotEmpty(result); + + // Verify that all commands have metadata + foreach (var command in result) + { + Assert.NotNull(command.Metadata); + + // Verify that metadata has the expected properties + // Destructive, ReadOnly, Idempotent, OpenWorld, Secret, LocalRequired + var metadata = command.Metadata; + + // Check that at least the main properties are accessible + Assert.True(metadata.Destructive || !metadata.Destructive, "Destructive should be defined"); + Assert.True(metadata.ReadOnly || !metadata.ReadOnly, "ReadOnly should be defined"); + Assert.True(metadata.Idempotent || !metadata.Idempotent, "Idempotent should be defined"); + Assert.True(metadata.OpenWorld || !metadata.OpenWorld, "OpenWorld should be defined"); + Assert.True(metadata.Secret || !metadata.Secret, "Secret should be defined"); + Assert.True(metadata.LocalRequired || !metadata.LocalRequired, "LocalRequired should be defined"); + } + } + + /// + /// Verifies that the --name-only option returns only tool names without descriptions. + /// + [Fact] + public async Task ExecuteAsync_WithNameOption_ReturnsOnlyToolNames() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--name-only" }); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var result = DeserializeToolNamesResult(response.Results); + Assert.NotNull(result); + Assert.NotNull(result.Names); + Assert.NotEmpty(result.Names); + + // Validate that the response only contains Names field and no other fields + var json = JsonSerializer.Serialize(response.Results); + var jsonElement = JsonSerializer.Deserialize(json); + + // Verify that only the "names" property exists + Assert.True(jsonElement.TryGetProperty("names", out _), "Response should contain 'names' property"); + + // Count the number of properties - should only be 1 (names) + var propertyCount = jsonElement.EnumerateObject().Count(); + Assert.Equal(1, propertyCount); + + // Explicitly verify that description and command fields are not present + Assert.False(jsonElement.TryGetProperty("description", out _), "Response should not contain 'description' property when using --name-only option"); + Assert.False(jsonElement.TryGetProperty("command", out _), "Response should not contain 'command' property when using --name-only option"); + Assert.False(jsonElement.TryGetProperty("options", out _), "Response should not contain 'options' property when using --name-only option"); + Assert.False(jsonElement.TryGetProperty("metadata", out _), "Response should not contain 'metadata' property when using --name-only option"); + + // Verify that all names are properly formatted tokenized names + foreach (var name in result.Names) + { + Assert.False(string.IsNullOrWhiteSpace(name), "Tool name should not be empty"); + Assert.DoesNotContain(" ", name); + } + + // Should contain some well-known commands + Assert.Contains(result.Names, name => name.Contains("subscription")); + Assert.Contains(result.Names, name => name.Contains("storage")); + Assert.Contains(result.Names, name => name.Contains("keyvault")); + } + + /// + /// Verifies that the --namespace option filters tools correctly for a single namespace. + /// + [Fact] + public async Task ExecuteAsync_WithSingleNamespaceOption_FiltersCorrectly() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--namespace", "storage" }); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var result = DeserializeResults(response.Results); + Assert.NotNull(result); + Assert.NotEmpty(result); + + // All commands should be from the storage namespace + foreach (var command in result) + { + Assert.StartsWith("storage", command.Command); + } + + // Should contain some well-known storage commands + Assert.Contains(result, cmd => cmd.Command == "storage account get"); + } + + /// + /// Verifies that multiple --namespace options work correctly. + /// + [Fact] + public async Task ExecuteAsync_WithMultipleNamespaceOptions_FiltersCorrectly() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--namespace", "storage", "--namespace", "keyvault" }); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var result = DeserializeResults(response.Results); + Assert.NotNull(result); + Assert.NotEmpty(result); + + // All commands should be from either storage or keyvault namespaces + foreach (var command in result) + { + var isStorageCommand = command.Command.StartsWith("storage"); + var isKeyvaultCommand = command.Command.StartsWith("keyvault"); + Assert.True(isStorageCommand || isKeyvaultCommand, + $"Command '{command.Command}' should be from storage or keyvault namespace"); + } + + // Should contain commands from both namespaces + Assert.Contains(result, cmd => cmd.Command.StartsWith("storage")); + Assert.Contains(result, cmd => cmd.Command.StartsWith("keyvault")); + } + + /// + /// Verifies that --name-only and --namespace options work together correctly. + /// + [Fact] + public async Task ExecuteAsync_WithNameAndNamespaceOptions_FiltersAndReturnsNamesOnly() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--name-only", "--namespace", "storage" }); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var result = DeserializeToolNamesResult(response.Results); + Assert.NotNull(result); + Assert.NotNull(result.Names); + Assert.NotEmpty(result.Names); + + // Validate that the response only contains Names field and no other fields + var json = JsonSerializer.Serialize(response.Results); + var jsonElement = JsonSerializer.Deserialize(json); + + // Verify that only the "names" property exists + Assert.True(jsonElement.TryGetProperty("names", out _), "Response should contain 'names' property"); + + // Count the number of properties - should only be 1 (names) + var propertyCount = jsonElement.EnumerateObject().Count(); + Assert.Equal(1, propertyCount); + + // All names should be from the storage namespace + foreach (var name in result.Names) + { + Assert.StartsWith("storage_", name); + } + + // Should contain some well-known storage commands + Assert.Contains(result.Names, name => name.Contains("account_get")); + } + + /// + /// Verifies that --name-only with multiple --namespace options works correctly. + /// + [Fact] + public async Task ExecuteAsync_WithNameAndMultipleNamespaceOptions_FiltersAndReturnsNamesOnly() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--name-only", "--namespace", "storage", "--namespace", "keyvault" }); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var result = DeserializeToolNamesResult(response.Results); + Assert.NotNull(result); + Assert.NotNull(result.Names); + Assert.NotEmpty(result.Names); + + // Validate that the response only contains Names field and no other fields + var json = JsonSerializer.Serialize(response.Results); + var jsonElement = JsonSerializer.Deserialize(json); + + // Verify that only the "names" property exists + Assert.True(jsonElement.TryGetProperty("names", out _), "Response should contain 'names' property"); + + // Count the number of properties - should only be 1 (names) + var propertyCount = jsonElement.EnumerateObject().Count(); + Assert.Equal(1, propertyCount); + + // All names should be from either storage or keyvault namespaces + foreach (var name in result.Names) + { + var isStorageName = name.StartsWith("storage_"); + var isKeyvaultName = name.StartsWith("keyvault_"); + Assert.True(isStorageName || isKeyvaultName, + $"Tool name '{name}' should be from storage or keyvault namespace"); + } + + // Should contain names from both namespaces + Assert.Contains(result.Names, name => name.StartsWith("storage_")); + Assert.Contains(result.Names, name => name.StartsWith("keyvault_")); + } + + /// + /// Verifies that option binding works correctly for the new options. + /// + [Fact] + public void BindOptions_WithNewOptions_BindsCorrectly() + { + // Arrange + var parseResult = _commandDefinition.Parse(new[] { "--name-only", "--namespace", "storage", "--namespace", "keyvault" }); + + // Use reflection to call the protected BindOptions method + var bindOptionsMethod = typeof(ToolsListCommand).GetMethod("BindOptions", + System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + Assert.NotNull(bindOptionsMethod); + + // Act + var options = bindOptionsMethod.Invoke(_command, new object?[] { parseResult }) as ToolsListOptions; + + // Assert + Assert.NotNull(options); + Assert.True(options.NameOnly); + Assert.False(options.NamespaceMode); + Assert.Equal(2, options.Namespaces.Count); + Assert.Contains("storage", options.Namespaces); + Assert.Contains("keyvault", options.Namespaces); + } + + /// + /// Verifies that parsing the new options works correctly. + /// + [Fact] + public void CanParseNewOptions() + { + // Arrange & Act + var parseResult1 = _commandDefinition.Parse(["--name-only"]); + var parseResult2 = _commandDefinition.Parse(["--namespace", "storage"]); + var parseResult3 = _commandDefinition.Parse(["--name-only", "--namespace", "storage", "--namespace", "keyvault"]); + + // Assert + Assert.False(parseResult1.Errors.Any(), $"Parse errors for --name-only: {string.Join(", ", parseResult1.Errors)}"); + Assert.False(parseResult2.Errors.Any(), $"Parse errors for --namespace: {string.Join(", ", parseResult2.Errors)}"); + Assert.False(parseResult3.Errors.Any(), $"Parse errors for combined options: {string.Join(", ", parseResult3.Errors)}"); + + // Verify values + Assert.True(parseResult1.GetValueOrDefault(ToolsListOptionDefinitions.NameOnly.Name)); + + var namespaces2 = parseResult2.GetValueOrDefault(ToolsListOptionDefinitions.Namespace.Name); + Assert.NotNull(namespaces2); + Assert.Single(namespaces2); + Assert.Equal("storage", namespaces2[0]); + + var namespaces3 = parseResult3.GetValueOrDefault(ToolsListOptionDefinitions.Namespace.Name); + Assert.NotNull(namespaces3); + Assert.Equal(2, namespaces3.Length); + Assert.Contains("storage", namespaces3); + Assert.Contains("keyvault", namespaces3); + } + + /// + /// Verifies that --namespace-mode and --name-only work together correctly. + /// + [Fact] + public async Task ExecuteAsync_WithNamespaceModeAndNameOnly_ReturnsNamespaceNamesOnly() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--namespace-mode", "--name-only" }); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var result = DeserializeToolNamesResult(response.Results); + Assert.NotNull(result); + Assert.NotNull(result.Names); + Assert.NotEmpty(result.Names); + + // Validate that the response only contains Names field and no other fields + var json = JsonSerializer.Serialize(response.Results); + var jsonElement = JsonSerializer.Deserialize(json); + + // Verify that only the "names" property exists + Assert.True(jsonElement.TryGetProperty("names", out _), "Response should contain 'names' property"); + + // Count the number of properties - should only be 1 (names) + var propertyCount = jsonElement.EnumerateObject().Count(); + Assert.Equal(1, propertyCount); + + // Should contain only namespace names (not individual commands) + Assert.Contains(result.Names, name => name.Equals("subscription", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(result.Names, name => name.Equals("storage", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(result.Names, name => name.Equals("keyvault", StringComparison.OrdinalIgnoreCase)); + } + + /// + /// Verifies that --namespace-mode, --name-only, and --namespace filtering work together correctly. + /// + [Fact] + public async Task ExecuteAsync_WithNamespaceModeNameOnlyAndNamespaceFilter_ReturnsFilteredNamespaceNamesOnly() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--namespace-mode", "--name-only", "--namespace", "storage" }); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var result = DeserializeToolNamesResult(response.Results); + Assert.NotNull(result); + Assert.NotNull(result.Names); + Assert.NotEmpty(result.Names); + + // Validate that the response only contains Names field and no other fields + var json = JsonSerializer.Serialize(response.Results); + var jsonElement = JsonSerializer.Deserialize(json); + + // Verify that only the "names" property exists + Assert.True(jsonElement.TryGetProperty("names", out _), "Response should contain 'names' property"); + + // Count the number of properties - should only be 1 (names) + var propertyCount = jsonElement.EnumerateObject().Count(); + Assert.Equal(1, propertyCount); + + // Should contain only storage namespace (and possibly surfaced storage-related commands) + foreach (var name in result.Names) + { + Assert.True(name.Equals("storage", StringComparison.OrdinalIgnoreCase) || + name.StartsWith("storage ", StringComparison.OrdinalIgnoreCase), + $"Name '{name}' should be from storage namespace"); + } + } + + /// + /// Verifies that --namespace-mode with multiple namespace filters works correctly. + /// + [Fact] + public async Task ExecuteAsync_WithNamespaceModeAndMultipleNamespaces_ReturnsFilteredNamespaces() + { + // Arrange + var args = _commandDefinition.Parse(new[] { "--namespace-mode", "--namespace", "storage", "--namespace", "keyvault" }); + + // Act + var response = await _command.ExecuteAsync(_context, args, TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(response); + Assert.Equal(HttpStatusCode.OK, response.Status); + Assert.NotNull(response.Results); + + var result = DeserializeResults(response.Results); + Assert.NotNull(result); + Assert.NotEmpty(result); + + // Should contain only storage and keyvault namespaces + foreach (var command in result) + { + var isStorageNamespace = command.Name.Equals("storage", StringComparison.OrdinalIgnoreCase); + var isKeyvaultNamespace = command.Name.Equals("keyvault", StringComparison.OrdinalIgnoreCase); + var isStorageCommand = command.Command.StartsWith("storage ", StringComparison.OrdinalIgnoreCase); + var isKeyvaultCommand = command.Command.StartsWith("keyvault ", StringComparison.OrdinalIgnoreCase); + + Assert.True(isStorageNamespace || isKeyvaultNamespace || isStorageCommand || isKeyvaultCommand, + $"Command '{command.Command}' (Name: '{command.Name}') should be from storage or keyvault namespace"); + } + + // Should contain both namespaces + Assert.Contains(result, cmd => cmd.Name.Equals("storage", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(result, cmd => cmd.Name.Equals("keyvault", StringComparison.OrdinalIgnoreCase)); + } } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/AssemblyAttributes.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/AssemblyAttributes.cs new file mode 100644 index 0000000000..e61d51b217 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/AssemblyAttributes.cs @@ -0,0 +1,2 @@ +[assembly: Azure.Mcp.Tests.Helpers.ClearEnvironmentVariablesBeforeTest] +[assembly: Xunit.CollectionBehavior(Xunit.CollectionBehavior.CollectionPerAssembly)] diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Client/MockClientTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Client/MockClientTests.cs index 555fac706f..ab2d74fba1 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Client/MockClientTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Client/MockClientTests.cs @@ -19,13 +19,13 @@ public MockClientTests() _options = CreateOptions(); } - private static McpServerOptions CreateOptions(ServerCapabilities? capabilities = null) + private static McpServerOptions CreateOptions(McpServerHandlers? serverHandlers = null) { return new McpServerOptions { ProtocolVersion = "2024", InitializationTimeout = TimeSpan.FromSeconds(30), - Capabilities = capabilities, + Handlers = serverHandlers ?? new(), ServerInfo = new Implementation { Name = "Azure MCP", Version = "1.0.0-beta" } }; } @@ -35,7 +35,7 @@ public async Task Invoke_Ping_Request_To_Server() { await Invoke_Request_To_Server( method: "ping", - serverCapabilities: null, + serverHandlers: null, configureOptions: null, assertResult: response => { @@ -49,7 +49,7 @@ public async Task Invoke_Init_Command() { await Invoke_Request_To_Server( method: "initialize", - serverCapabilities: null, + serverHandlers: null, configureOptions: null, assertResult: response => { @@ -66,19 +66,17 @@ public async Task Invoke_Az_List_Subscription_Command() { await Invoke_Request_To_Server( method: "tools/call", - new ServerCapabilities + new() { - Tools = new() + CallToolHandler = (request, ct) => { - CallToolHandler = (request, ct) => + if (request.Params?.Name == "azmcp_subscription_list") { - if (request.Params?.Name == "azmcp_subscription_list") + return ValueTask.FromResult(new CallToolResult { - return ValueTask.FromResult(new CallToolResult - { - Content = - [ - new TextContentBlock + Content = + [ + new TextContentBlock { Text = JsonSerializer.Serialize(new { @@ -89,14 +87,13 @@ await Invoke_Request_To_Server( } }) } - ] - }); - } + ] + }); + } - throw new Exception($"Unhandled tool name: {request.Params?.Name}"); - }, - ListToolsHandler = (request, ct) => throw new NotImplementedException(), - } + throw new Exception($"Unhandled tool name: {request.Params?.Name}"); + }, + ListToolsHandler = (request, ct) => throw new NotImplementedException(), }, requestParams: JsonSerializer.SerializeToNode(new { @@ -127,19 +124,16 @@ public async Task Invoke_List_Tools_Command() { await Invoke_Request_To_Server( method: "tools/list", - new ServerCapabilities + new() { - Tools = new() + ListToolsHandler = (request, ct) => { - ListToolsHandler = (request, ct) => + return ValueTask.FromResult(new ListToolsResult { - return ValueTask.FromResult(new ListToolsResult - { - Tools = [new() { Name = "ListTools" }] - }); - }, - CallToolHandler = (request, ct) => throw new NotImplementedException(), - } + Tools = [new() { Name = "ListTools" }] + }); + }, + CallToolHandler = (request, ct) => throw new NotImplementedException(), }, configureOptions: null, assertResult: response => @@ -156,19 +150,16 @@ public async Task Invoke_Dummy_Tool() { await Invoke_Request_To_Server( method: "tools/call", - new ServerCapabilities + new() { - Tools = new() + CallToolHandler = (request, ct) => { - CallToolHandler = (request, ct) => + return ValueTask.FromResult(new CallToolResult { - return ValueTask.FromResult(new CallToolResult - { - Content = [new TextContentBlock { Text = "dummyTool" }] - }); - }, - ListToolsHandler = (request, ct) => throw new NotImplementedException(), - } + Content = [new TextContentBlock { Text = "dummyTool" }] + }); + }, + ListToolsHandler = (request, ct) => throw new NotImplementedException(), }, configureOptions: null, assertResult: response => @@ -185,21 +176,18 @@ public async Task Invoke_Invalid_Tool_Returns_Error() { await Invoke_Request_To_Server( method: "tools/call", - new ServerCapabilities + new() { - Tools = new() + CallToolHandler = (request, ct) => { - CallToolHandler = (request, ct) => + // Simulate the behavior when an invalid tool is called + return ValueTask.FromResult(new CallToolResult { - // Simulate the behavior when an invalid tool is called - return ValueTask.FromResult(new CallToolResult - { - Content = [new TextContentBlock { Text = $"The tool {request.Params?.Name} was not found" }], - IsError = true - }); - }, - ListToolsHandler = (request, ct) => throw new NotImplementedException(), - } + Content = [new TextContentBlock { Text = $"The tool {request.Params?.Name} was not found" }], + IsError = true + }); + }, + ListToolsHandler = (request, ct) => throw new NotImplementedException(), }, requestParams: JsonSerializer.SerializeToNode(new { @@ -220,10 +208,10 @@ await Invoke_Request_To_Server( } - private async Task Invoke_Request_To_Server(string method, ServerCapabilities? serverCapabilities, Action? configureOptions, Action assertResult) + private async Task Invoke_Request_To_Server(string method, McpServerHandlers? serverHandlers, Action? configureOptions, Action assertResult) { await Invoke_Request_To_Server( - serverCapabilities: serverCapabilities, + serverHandlers: serverHandlers, method: method, requestParams: null, configureOptions: configureOptions, @@ -231,13 +219,13 @@ await Invoke_Request_To_Server( ); } - private async Task Invoke_Request_To_Server(string method, ServerCapabilities? serverCapabilities, JsonNode? requestParams, Action? configureOptions, Action assertResult) + private async Task Invoke_Request_To_Server(string method, McpServerHandlers? serverHandlers, JsonNode? requestParams, Action? configureOptions, Action assertResult) { await using var transport = new CustomTestTransport(); - var options = CreateOptions(serverCapabilities); + var options = CreateOptions(serverHandlers); configureOptions?.Invoke(options); - await using var server = McpServerFactory.Create(transport, options); + await using var server = McpServer.Create(transport, options); var runTask = server.RunAsync(); var receivedMessage = new TaskCompletionSource(); diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Commands/CommandFactoryTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Commands/CommandFactoryTests.cs index b1700e5ff6..ffb257b049 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Commands/CommandFactoryTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Commands/CommandFactoryTests.cs @@ -4,9 +4,11 @@ using System.CommandLine; using Azure.Mcp.Core.Areas; using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Configuration; using Azure.Mcp.Core.Services.Telemetry; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; using NSubstitute; using Xunit; @@ -22,14 +24,26 @@ public class CommandFactoryTests private readonly IServiceProvider _serviceProvider; private readonly ILogger _logger; private readonly ITelemetryService _telemetryService; + private readonly AzureMcpServerConfiguration _serverConfiguration; + private readonly IOptions _configurationOptions; public CommandFactoryTests() { var services = new ServiceCollection(); services.AddLogging(); + + _serverConfiguration = new AzureMcpServerConfiguration + { + Name = "Test Server", + Version = "Test Version", + DisplayName = "Test Display", + RootCommandGroupName = "azmcp" + }; + _serviceProvider = services.BuildServiceProvider(); _logger = Substitute.For>(); _telemetryService = Substitute.For(); + _configurationOptions = Microsoft.Extensions.Options.Options.Create(_serverConfiguration); } [Fact] @@ -123,7 +137,7 @@ public void Constructor_Throws_AreaSetups_Duplicate() // Act & Assert Assert.Throws(() => - new CommandFactory(_serviceProvider, serviceAreas, _telemetryService, _logger)); + new CommandFactory(_serviceProvider, serviceAreas, _telemetryService, _configurationOptions, _logger)); } [Fact] @@ -138,7 +152,7 @@ public void Constructor_Throws_AreaSetups_EmptyName() // Act & Assert Assert.Throws(() => - new CommandFactory(_serviceProvider, serviceAreas, _telemetryService, _logger)); + new CommandFactory(_serviceProvider, serviceAreas, _telemetryService, _configurationOptions, _logger)); } [Theory] @@ -153,20 +167,14 @@ public void GetServiceArea_Existing_SetupArea(string commandName, string expecte var area3 = CreateIAreaSetup("name3"); var serviceAreas = new List { area1, area3, area2 }; - var factory = new CommandFactory(_serviceProvider, serviceAreas, _telemetryService, _logger); - - // All commands in command factory are prefixed with the root command group, "azmcp". - var commandNameToTry = "azmcp" + CommandFactory.Separator + commandName; + var factory = new CommandFactory(_serviceProvider, serviceAreas, _telemetryService, _configurationOptions, _logger); // Act - var actual = factory.GetServiceArea(commandNameToTry); - // Try in the case that the root prefix is not used. This is in the case that the tool // is created using the IAreaSetup name as root. var actual2 = factory.GetServiceArea(commandName); // Assert - Assert.Equal(expected, actual); Assert.Equal(expected, actual2); } @@ -179,7 +187,7 @@ public void GetServiceArea_DoesNotExist() var area3 = CreateIAreaSetup("name3"); var serviceAreas = new List { area1, area2, area3 }; - var factory = new CommandFactory(_serviceProvider, serviceAreas, _telemetryService, _logger); + var factory = new CommandFactory(_serviceProvider, serviceAreas, _telemetryService, _configurationOptions, _logger); // All commands created in command factory are prefixed with the root command group, "azmcp". var commandNameToTry = "azmcp" + CommandFactory.Separator + "name0_subgroup2_directCommand4"; @@ -199,7 +207,7 @@ public void CommandDictionaryCreated_WithPrefix() var commandGroup = CreateCommandGroup(); // Act - var commandDictionary = CommandFactory.CreateCommandDictionary(commandGroup, prefix); + var commandDictionary = CommandFactory.CreateCommandDictionaryInner(commandGroup, prefix); // Assert Assert.NotNull(commandDictionary); @@ -221,7 +229,7 @@ public void CommandDictionaryCreated_EmptyPrefix() var commandGroup = CreateCommandGroup(); // Act - var commandDictionary = CommandFactory.CreateCommandDictionary(commandGroup, string.Empty); + var commandDictionary = CommandFactory.CreateCommandDictionaryInner(commandGroup, string.Empty); // Assert Assert.NotNull(commandDictionary); diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Extensions/CommandResultExtensionsTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Extensions/CommandResultExtensionsTests.cs index 54c15cf318..155bfd9896 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Extensions/CommandResultExtensionsTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Extensions/CommandResultExtensionsTests.cs @@ -224,4 +224,283 @@ public void GetValueOrDefault_WithNullableIntNullDefaultValue_ReturnsNull() // Assert Assert.Null(result); } + + [Fact] + public void GetValueWithoutDefault_WithExplicitStringValue_ReturnsValue() + { + // Arrange + var option = new Option("--name"); + var command = new Command("test") { option }; + var parseResult = command.Parse("--name test-value"); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.Equal("test-value", result); + } + + [Fact] + public void GetValueWithoutDefault_WithMissingStringValue_ReturnsNull() + { + // Arrange + var option = new Option("--name"); + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetValueWithoutDefault_WithExplicitIntValue_ReturnsValue() + { + // Arrange + var option = new Option("--count"); + var command = new Command("test") { option }; + var parseResult = command.Parse("--count 42"); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.Equal(42, result); + } + + [Fact] + public void GetValueWithoutDefault_WithMissingIntValue_ReturnsNull() + { + // Arrange + var option = new Option("--count"); + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetValueWithoutDefault_WithExplicitLongValue_ReturnsValue() + { + // Arrange + var option = new Option("--max-size-bytes"); + var command = new Command("test") { option }; + var parseResult = command.Parse("--max-size-bytes 1073741824"); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.Equal(1073741824L, result); + } + + [Fact] + public void GetValueWithoutDefault_WithMissingLongValue_ReturnsNull() + { + // Arrange + var option = new Option("--max-size-bytes"); + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetValueWithoutDefault_WithExplicitBoolValue_ReturnsValue() + { + // Arrange + var option = new Option("--zone-redundant"); + var command = new Command("test") { option }; + var parseResult = command.Parse("--zone-redundant true"); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.True(result); + } + + [Fact] + public void GetValueWithoutDefault_WithMissingBoolValue_ReturnsNull() + { + // Arrange + var option = new Option("--zone-redundant"); + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetValueWithoutDefault_WithExplicitZeroIntValue_ReturnsZero() + { + // Arrange + var option = new Option("--count"); + var command = new Command("test") { option }; + var parseResult = command.Parse("--count 0"); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.Equal(0, result); + } + + [Fact] + public void GetValueWithoutDefault_WithExplicitFalseBoolValue_ReturnsFalse() + { + // Arrange + var option = new Option("--zone-redundant"); + var command = new Command("test") { option }; + var parseResult = command.Parse("--zone-redundant false"); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.False(result); + } + + [Fact] + public void GetValueWithoutDefault_WithBoolSwitch_ReturnsTrue() + { + // Arrange + var option = new Option("--verbose"); + var command = new Command("test") { option }; + var parseResult = command.Parse("--verbose"); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.True(result); + } + + [Fact] + public void GetValueWithoutDefault_WithMissingBoolSwitch_ReturnsNull() + { + // Arrange + var option = new Option("--verbose"); + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetValueWithoutDefault_WithDefaultValue_IgnoresDefault() + { + // Arrange + var option = new Option("--count") + { + DefaultValueFactory = _ => 42 + }; + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.Null(result); // Should ignore default and return null + } + + [Fact] + public void GetValueWithoutDefault_WithNullDefaultValue_ReturnsNull() + { + // Arrange + var option = new Option("--count") + { + DefaultValueFactory = _ => null + }; + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault(option); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetValueWithoutDefault_WithStringOptionName_WithExplicitValue_ReturnsValue() + { + // Arrange + var option = new Option("--name"); + var command = new Command("test") { option }; + var parseResult = command.Parse("--name test-value"); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault("--name"); + + // Assert + Assert.Equal("test-value", result); + } + + [Fact] + public void GetValueWithoutDefault_WithStringOptionName_WithMissingValue_ReturnsNull() + { + // Arrange + var option = new Option("--name"); + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault("--name"); + + // Assert + Assert.Null(result); + } + + [Fact] + public void GetValueWithoutDefault_WithStringOptionName_WithDefaultValue_IgnoresDefault() + { + // Arrange + var option = new Option("--name") + { + DefaultValueFactory = _ => "default-value" + }; + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault("--name"); + + // Assert + Assert.Null(result); // Should ignore default and return null + } + + [Fact] + public void GetValueWithoutDefault_WithStringOptionName_WithNonExistentOption_ReturnsNull() + { + // Arrange + var option = new Option("--name"); + var command = new Command("test") { option }; + var parseResult = command.Parse(""); + + // Act + var result = parseResult.CommandResult.GetValueWithoutDefault("--non-existent"); + + // Assert + Assert.Null(result); + } } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Extensions/McpServerElicitationExtensionsTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Extensions/McpServerElicitationExtensionsTests.cs index 356cef8d98..82bddcdc08 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Extensions/McpServerElicitationExtensionsTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Extensions/McpServerElicitationExtensionsTests.cs @@ -172,24 +172,14 @@ public async Task RequestElicitationAsync_WithNonSupportingClient_ThrowsNotSuppo var server = CreateMockServer(); server.ClientCapabilities.Returns((ClientCapabilities?)null); - var requestedSchema = new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["confirm"] = new JsonObject { ["type"] = "boolean" } - } - }; - var request = new ElicitationRequestParams { - Message = "Test message", - RequestedSchema = requestedSchema + Message = "Test message" }; // Act & Assert var exception = await Assert.ThrowsAsync( - () => server.RequestElicitationAsync(request, CancellationToken.None)); + () => server.RequestElicitationAsync(request, TestContext.Current.CancellationToken)); Assert.Contains("elicitation", exception.Message, StringComparison.OrdinalIgnoreCase); } @@ -205,30 +195,20 @@ public async Task RequestElicitationAsync_WithInvalidMessage_ThrowsArgumentExcep var clientCapabilities = new ClientCapabilities { Elicitation = new() }; server.ClientCapabilities.Returns(clientCapabilities); - var requestedSchema = new JsonObject - { - ["type"] = "object", - ["properties"] = new JsonObject - { - ["confirm"] = new JsonObject { ["type"] = "boolean" } - } - }; - var request = new ElicitationRequestParams { - Message = message!, - RequestedSchema = requestedSchema + Message = message! }; // Act & Assert await Assert.ThrowsAsync( - () => server.RequestElicitationAsync(request, CancellationToken.None)); + () => server.RequestElicitationAsync(request, TestContext.Current.CancellationToken)); } - private static IMcpServer CreateMockServer() + private static McpServer CreateMockServer() { // Create a mock server that we can configure without constructor issues - var server = Substitute.For(); + var server = Substitute.For(); // Set up default client capabilities server.ClientCapabilities.Returns(new ClientCapabilities()); diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Helpers/CommandHelperTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Helpers/CommandHelperTests.cs new file mode 100644 index 0000000000..c717c91456 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Helpers/CommandHelperTests.cs @@ -0,0 +1,114 @@ +using System.CommandLine; +using Azure.Mcp.Core.Areas.Group.Commands; +using Azure.Mcp.Core.Helpers; +using Microsoft.Extensions.Logging; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Helpers; + +public class CommandHelperTests +{ + [Fact] + public void GetSubscription_EmptySubscriptionParameter_ReturnsEnvironmentValue() + { + // Arrange + EnvironmentHelpers.SetAzureSubscriptionId("env-subs"); + var parseResult = GetParseResult(["--subscription", ""]); + + // Act + var actual = CommandHelper.GetSubscription(parseResult); + + // Assert + Assert.Equal("env-subs", actual); + } + + [Fact] + public void GetSubscription_MissingSubscriptionParameter_ReturnsEnvironmentValue() + { + // Arrange + EnvironmentHelpers.SetAzureSubscriptionId("env-subs"); + var parseResult = GetParseResult([]); + + // Act + var actual = CommandHelper.GetSubscription(parseResult); + + // Assert + Assert.Equal("env-subs", actual); + } + + [Fact] + public void GetSubscription_ValidSubscriptionParameter_ReturnsParameterValue() + { + // Arrange + EnvironmentHelpers.SetAzureSubscriptionId("env-subs"); + var parseResult = GetParseResult(["--subscription", "param-subs"]); + + // Act + var actual = CommandHelper.GetSubscription(parseResult); + + // Assert + Assert.Equal("param-subs", actual); + } + + [Fact] + public void GetSubscription_ParameterValueContainingSubscription_ReturnsEnvironmentValue() + { + // Arrange + EnvironmentHelpers.SetAzureSubscriptionId("env-subs"); + var parseResult = GetParseResult(["--subscription", "Azure subscription 1"]); + + // Act + var actual = CommandHelper.GetSubscription(parseResult); + + // Assert + Assert.Equal("env-subs", actual); + } + + [Fact] + public void GetSubscription_ParameterValueContainingDefault_ReturnsEnvironmentValue() + { + // Arrange + EnvironmentHelpers.SetAzureSubscriptionId("env-subs"); + var parseResult = GetParseResult(["--subscription", "Some default name"]); + + // Act + var actual = CommandHelper.GetSubscription(parseResult); + + // Assert + Assert.Equal("env-subs", actual); + } + + [Fact] + public void GetSubscription_NoEnvironmentVariableParameterValueContainingDefault_ReturnsParameterValue() + { + // Arrange + var parseResult = GetParseResult(["--subscription", "Some default name"]); + + // Act + var actual = CommandHelper.GetSubscription(parseResult); + + // Assert + Assert.Equal("Some default name", actual); + } + + [Fact] + public void GetSubscription_NoEnvironmentVariableParameterValueContainingSubscription_ReturnsParameterValue() + { + // Arrange + var parseResult = GetParseResult(["--subscription", "Azure subscription 1"]); + + // Act + var actual = CommandHelper.GetSubscription(parseResult); + + // Assert + Assert.Equal("Azure subscription 1", actual); + } + + private static ParseResult GetParseResult(params string[] args) + { + var command = new GroupListCommand(Substitute.For>()); + var commandDefinition = command.GetCommand(); + return commandDefinition.Parse(args); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Helpers/OptionParsingHelpersTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Helpers/OptionParsingHelpersTests.cs new file mode 100644 index 0000000000..665e5b4b45 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Helpers/OptionParsingHelpersTests.cs @@ -0,0 +1,386 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Helpers; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Helpers; + +public class OptionParsingHelpersTests +{ + #region ParseKeyValuePairStringToDictionary Tests + + [Theory] + [InlineData("Content-Type=application/json", "Content-Type", "application/json")] + [InlineData("Authorization=Bearer token", "Authorization", "Bearer token")] + [InlineData("X-Custom-Header=custom-value", "X-Custom-Header", "custom-value")] + public void ParseKeyValuePairStringToDictionary_SingleHeader_ReturnsCorrectDictionary(string input, string expectedKey, string expectedValue) + { + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input); + + // Assert + Assert.Single(result); + Assert.True(result.ContainsKey(expectedKey)); + Assert.Equal(expectedValue, result[expectedKey]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_MultipleHeaders_ReturnsCorrectDictionary() + { + // Arrange + var input = "Content-Type=application/json,Authorization=Bearer token,X-Custom=value"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal("application/json", result["Content-Type"]); + Assert.Equal("Bearer token", result["Authorization"]); + Assert.Equal("value", result["X-Custom"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_HeadersWithSpaces_TrimsCorrectly() + { + // Arrange + var input = " Content-Type = application/json , Authorization = Bearer token "; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("application/json", result["Content-Type"]); + Assert.Equal("Bearer token", result["Authorization"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_EmptyHeaderValues_HandlesCorrectly() + { + // Arrange + var input = "Header1=,Header2=value"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input); + + // Assert + Assert.Single(result); + Assert.Equal("value", result["Header2"]); + // Header1 should be ignored because it has empty value after split + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_InvalidFormat_IgnoresInvalidEntries() + { + // Arrange + var input = "ValidHeader=value,InvalidHeaderNoEquals,AnotherValid=test"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("value", result["ValidHeader"]); + Assert.Equal("test", result["AnotherValid"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_MultipleEqualsInValue_HandlesCorrectly() + { + // Arrange + var input = "Content-Type=application/json,Query=param1=value1¶m2=value2"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("application/json", result["Content-Type"]); + Assert.Equal("param1=value1¶m2=value2", result["Query"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_DuplicateHeaders_LastValueWins() + { + // Arrange + var input = "Content-Type=application/json,Content-Type=application/xml"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input); + + // Assert + Assert.Single(result); + Assert.Equal("application/xml", result["Content-Type"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_TrailingCommas_HandlesCorrectly() + { + // Arrange + var input = "Content-Type=application/json,,Authorization=Bearer token,"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("application/json", result["Content-Type"]); + Assert.Equal("Bearer token", result["Authorization"]); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ParseKeyValuePairStringToDictionary_NullOrWhitespace_ThrowsArgumentException(string input) + { + // Act & Assert + Assert.Throws(() => OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input)); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_NullInput_ThrowsArgumentException() + { + // Act & Assert + Assert.Throws(() => OptionParsingHelpers.ParseKeyValuePairStringToDictionary(null!)); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_HeaderWithSpecialCharacters_HandlesCorrectly() + { + // Arrange + var input = "X-Request-ID=abc-123-def,X-Custom-Header=value_with_underscores"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("abc-123-def", result["X-Request-ID"]); + Assert.Equal("value_with_underscores", result["X-Custom-Header"]); + } + + #endregion + + #region Custom Separator Tests + + [Theory] + [InlineData("Key1:Value1;Key2:Value2", ':', ';', 2)] + [InlineData("Name=John,Age=30", '=', ',', 2)] + [InlineData("a~b&c~d", '~', '&', 2)] + public void ParseKeyValuePairStringToDictionary_CustomSeparators_ParsesCorrectly(string input, char keyValueSeparator, char pairSeparator, int expectedCount) + { + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input, keyValueSeparator, pairSeparator); + + // Assert + Assert.Equal(expectedCount, result.Count); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_CustomSeparators_ColonAndSemicolon() + { + // Arrange + var input = "Header1:value1;Header2:value2;Header3:value3"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input, ':', ';'); + + // Assert + Assert.Equal(3, result.Count); + Assert.Equal("value1", result["Header1"]); + Assert.Equal("value2", result["Header2"]); + Assert.Equal("value3", result["Header3"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_CustomSeparators_TabAndNewline() + { + // Arrange + var input = "Name\tJohn\nAge\t30"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input, '\t', '\n'); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("John", result["Name"]); + Assert.Equal("30", result["Age"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_CustomSeparators_WithSpaces() + { + // Arrange + var input = " Key1 : Value1 ; Key2 : Value2 "; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input, ':', ';'); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("Value1", result["Key1"]); + Assert.Equal("Value2", result["Key2"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_CustomSeparators_ValueContainsPairSeparator() + { + // Arrange + var input = "URL:https://example.com:8080;Port:8080"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input, ':', ';'); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("https://example.com:8080", result["URL"]); + Assert.Equal("8080", result["Port"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_CustomSeparators_SameSeparatorLimitation() + { + // Arrange - When key-value and pair separators are the same, parsing becomes ambiguous + var input = "Key1|Value1|Key2|Value2"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input, '|', '|'); + + // Assert - This demonstrates the limitation - only the last valid pair is captured + // because the first split creates: ["Key1", "Value1", "Key2", "Value2"] + // and only pairs with exactly 2 elements after key-value split are valid + Assert.True(result.Count <= 1); // May be 0 or 1 depending on parsing logic + } + + #endregion + + #region StringComparer Tests + + [Fact] + public void ParseKeyValuePairStringToDictionary_OrdinalIgnoreCase_CaseInsensitive() + { + // Arrange + var input = "Content-Type=application/json,content-type=application/xml"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input, StringComparer.OrdinalIgnoreCase); + + // Assert + Assert.Single(result); + Assert.Equal("application/xml", result["Content-Type"]); + Assert.Equal("application/xml", result["content-type"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_Ordinal_CaseSensitive() + { + // Arrange + var input = "Content-Type=application/json,content-type=application/xml"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input, StringComparer.Ordinal); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("application/json", result["Content-Type"]); + Assert.Equal("application/xml", result["content-type"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_CurrentCultureIgnoreCase_CaseInsensitive() + { + // Arrange + var input = "NAME=John,name=Jane"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input, StringComparer.CurrentCultureIgnoreCase); + + // Assert + Assert.Single(result); + Assert.Equal("Jane", result["NAME"]); + Assert.Equal("Jane", result["name"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_DefaultComparer_IsOrdinalIgnoreCase() + { + // Arrange + var input = "Header=value1,HEADER=value2"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input); + + // Assert + Assert.Single(result); + Assert.Equal("value2", result["Header"]); + Assert.Equal("value2", result["HEADER"]); + } + + #endregion + + #region Combined Custom Separators and StringComparer Tests + + [Fact] + public void ParseKeyValuePairStringToDictionary_CustomSeparatorsWithStringComparer_CaseSensitive() + { + // Arrange + var input = "Key1:Value1;key1:Value2"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input, StringComparer.Ordinal, ':', ';'); + + // Assert + Assert.Equal(2, result.Count); + Assert.Equal("Value1", result["Key1"]); + Assert.Equal("Value2", result["key1"]); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_CustomSeparatorsWithStringComparer_CaseInsensitive() + { + // Arrange + var input = "Key1:Value1;KEY1:Value2"; + + // Act + var result = OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input, StringComparer.OrdinalIgnoreCase, ':', ';'); + + // Assert + Assert.Single(result); + Assert.Equal("Value2", result["Key1"]); + Assert.Equal("Value2", result["KEY1"]); + } + + #endregion + + #region Argument Validation Tests + + [Fact] + public void ParseKeyValuePairStringToDictionary_NullStringComparer_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => + OptionParsingHelpers.ParseKeyValuePairStringToDictionary("key=value", null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + public void ParseKeyValuePairStringToDictionary_WithStringComparer_NullOrWhitespace_ThrowsArgumentException(string input) + { + // Act & Assert + Assert.Throws(() => + OptionParsingHelpers.ParseKeyValuePairStringToDictionary(input, StringComparer.Ordinal)); + } + + [Fact] + public void ParseKeyValuePairStringToDictionary_WithStringComparer_NullInput_ThrowsArgumentException() + { + // Act & Assert + Assert.Throws(() => + OptionParsingHelpers.ParseKeyValuePairStringToDictionary(null!, StringComparer.Ordinal)); + } + + #endregion +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Infrastructure/VersionSyncTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Infrastructure/VersionSyncTests.cs index 36d19d9423..2d310cb4d4 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Infrastructure/VersionSyncTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Infrastructure/VersionSyncTests.cs @@ -79,8 +79,10 @@ private static string GetDotNetRuntimeVersionFromDockerfile(string dockerfilePat { var dockerfileContent = File.ReadAllText(dockerfilePath); - // Look for patterns like: FROM mcr.microsoft.com/dotnet/aspnet:9.0.5-bookworm-slim - var pattern = @"FROM\s+mcr\.microsoft\.com/dotnet/aspnet:(\d+\.\d+\.\d+)"; + // Look for patterns like: + // - FROM mcr.microsoft.com/dotnet/aspnet:9.0.5-bookworm-slim + // - FROM mcr.microsoft.com/dotnet/aspnet:10.0-alpine + var pattern = @"FROM\s+mcr\.microsoft\.com/dotnet/aspnet:(\d+\.\d+(\.\d+)?)"; var match = Regex.Match(dockerfileContent, pattern); if (match.Success) diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/Authentication/CustomChainedCredentialTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/Authentication/CustomChainedCredentialTests.cs new file mode 100644 index 0000000000..a09d8f470c --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/Authentication/CustomChainedCredentialTests.cs @@ -0,0 +1,268 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using Azure.Core; +using Microsoft.Extensions.Logging; +using Xunit; + +namespace Azure.Mcp.Core.UnitTests.Services.Azure.Authentication; + +/// +/// Tests for CustomChainedCredential configuration behavior. +/// These tests verify that credentials are created correctly based on environment variable settings. +/// Note: These tests verify creation behavior only. Actual authentication behavior requires live credentials. +/// +public class CustomChainedCredentialTests +{ + /// + /// Tests that default behavior (no AZURE_TOKEN_CREDENTIALS set) creates a credential successfully. + /// Expected: Uses default credential chain with InteractiveBrowserCredential fallback. + /// + [Fact] + public void DefaultBehavior_CreatesCredentialSuccessfully() + { + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Tests that dev mode (AZURE_TOKEN_CREDENTIALS="dev") creates a credential successfully. + /// Expected: Uses development credentials with InteractiveBrowserCredential fallback. + /// + [Fact] + public void DevMode_CreatesCredentialSuccessfully() + { + // Arrange + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "dev"); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Tests that prod mode (AZURE_TOKEN_CREDENTIALS="prod") creates a credential successfully. + /// Expected: Uses production credentials (EnvironmentCredential, WorkloadIdentityCredential, ManagedIdentityCredential) + /// WITHOUT InteractiveBrowserCredential fallback. + /// + [Fact] + public void ProdMode_CreatesCredentialSuccessfully() + { + // Arrange + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "prod"); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Tests that specific credential (AZURE_TOKEN_CREDENTIALS="ManagedIdentityCredential") creates successfully. + /// Expected: Uses ONLY ManagedIdentityCredential without InteractiveBrowserCredential fallback. + /// + [Fact] + public void SpecificCredential_ManagedIdentity_CreatesCredentialSuccessfully() + { + // Arrange + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "ManagedIdentityCredential"); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Tests that specific credential (AZURE_TOKEN_CREDENTIALS="AzureCliCredential") creates successfully. + /// Expected: Uses ONLY AzureCliCredential without InteractiveBrowserCredential fallback. + /// + [Fact] + public void SpecificCredential_AzureCli_CreatesCredentialSuccessfully() + { + // Arrange + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "AzureCliCredential"); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Tests that explicit InteractiveBrowserCredential request creates successfully. + /// Expected: Uses InteractiveBrowserCredential when explicitly requested. + /// + [Fact] + public void SpecificCredential_InteractiveBrowser_CreatesCredentialSuccessfully() + { + // Arrange + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "InteractiveBrowserCredential"); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Tests all supported specific credential types create successfully. + /// Expected: Each credential type creates without errors. + /// + [Theory] + [InlineData("EnvironmentCredential")] + [InlineData("WorkloadIdentityCredential")] + [InlineData("VisualStudioCredential")] + [InlineData("VisualStudioCodeCredential")] + [InlineData("AzurePowerShellCredential")] + [InlineData("AzureDeveloperCliCredential")] + public void SpecificCredential_VariousTypes_CreateCredentialSuccessfully(string credentialType) + { + // Arrange + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", credentialType); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Tests that User-Assigned Managed Identity (AZURE_CLIENT_ID set) creates successfully. + /// Expected: ManagedIdentityCredential is configured with the specified clientId. + /// + [Fact] + public void ManagedIdentityCredential_WithClientId_CreatesCredentialSuccessfully() + { + // Arrange + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "ManagedIdentityCredential"); + Environment.SetEnvironmentVariable("AZURE_CLIENT_ID", "12345678-1234-1234-1234-123456789012"); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Tests that System-Assigned Managed Identity (no AZURE_CLIENT_ID) creates successfully. + /// Expected: ManagedIdentityCredential is configured for system-assigned identity. + /// + [Fact] + public void ManagedIdentityCredential_WithoutClientId_CreatesCredentialSuccessfully() + { + // Arrange + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "ManagedIdentityCredential"); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Tests that "only broker credential" mode creates InteractiveBrowserCredential successfully. + /// Expected: Uses only InteractiveBrowserCredential with broker support. + /// + [Fact] + public void OnlyUseBrokerCredential_CreatesCredentialSuccessfully() + { + // Arrange + Environment.SetEnvironmentVariable("AZURE_MCP_ONLY_USE_BROKER_CREDENTIAL", "true"); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Tests that VS Code context without explicit setting creates credential successfully. + /// Expected: When VSCODE_PID is set and AZURE_TOKEN_CREDENTIALS is not set, + /// prioritizes VS Code credential in the chain. + /// + [Fact] + public void VSCodeContext_WithoutExplicitSetting_CreatesCredentialSuccessfully() + { + // Arrange + Environment.SetEnvironmentVariable("VSCODE_PID", "12345"); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Tests that VS Code context with explicit prod setting respects the explicit setting. + /// Expected: When both VSCODE_PID and AZURE_TOKEN_CREDENTIALS are set, + /// AZURE_TOKEN_CREDENTIALS takes precedence. + /// + [Fact] + public void VSCodeContext_WithExplicitProdSetting_CreatesCredentialSuccessfully() + { + // Arrange + Environment.SetEnvironmentVariable("VSCODE_PID", "12345"); + Environment.SetEnvironmentVariable("AZURE_TOKEN_CREDENTIALS", "prod"); + + // Act + var credential = CreateCustomChainedCredential(); + + // Assert + Assert.NotNull(credential); + Assert.IsAssignableFrom(credential); + } + + /// + /// Helper method to create CustomChainedCredential using reflection since it's an internal class. + /// + private static TokenCredential CreateCustomChainedCredential() + { + var assembly = typeof(global::Azure.Mcp.Core.Services.Azure.Authentication.IAzureTokenCredentialProvider).Assembly; + var customChainedCredentialType = assembly.GetType("Azure.Mcp.Core.Services.Azure.Authentication.CustomChainedCredential"); + + Assert.NotNull(customChainedCredentialType); + + var constructor = customChainedCredentialType.GetConstructors(BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic) + .FirstOrDefault(c => + { + var parameters = c.GetParameters(); + return parameters.Length == 2 && + parameters[0].ParameterType == typeof(string) && + parameters[1].ParameterType == typeof(ILogger<>).MakeGenericType(customChainedCredentialType); + }); + + Assert.NotNull(constructor); + + var credential = constructor.Invoke([null, null]) as TokenCredential; + Assert.NotNull(credential); + + return credential; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/BaseAzureServiceTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/BaseAzureServiceTests.cs index 4c418db534..b9d13ab9a1 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/BaseAzureServiceTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/BaseAzureServiceTests.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Azure.Core; +using Azure.Mcp.Core.Areas.Server.Options; using Azure.Mcp.Core.Options; using Azure.Mcp.Core.Services.Azure; using Azure.Mcp.Core.Services.Azure.Tenant; @@ -20,18 +22,23 @@ public class BaseAzureServiceTests public BaseAzureServiceTests() { - _azureService = new TestAzureService(); - _tenantService.GetTenantId(TenantName).Returns(TenantId); + _azureService = new TestAzureService(_tenantService); + _tenantService.GetTenantId(TenantName, Arg.Any()).Returns(TenantId); + _tenantService.GetTokenCredentialAsync( + Arg.Any(), + Arg.Any()) + .Returns(Substitute.For()); + _tenantService.GetClient().Returns(_ => new HttpClient(new HttpClientHandler())); } [Fact] - public async Task CreateArmClientAsync_CreatesAndUsesCachedClient() + public async Task CreateArmClientAsync_DoesNotReuseClient() { // Act var tenantName2 = "Other-Tenant-Name"; var tenantId2 = "Other-Tenant-Id"; - _tenantService.GetTenantId(tenantName2).Returns(tenantId2); + _tenantService.GetTenantId(tenantName2, Arg.Any()).Returns(tenantId2); var retryPolicyArgs = new RetryPolicyOptions { @@ -43,35 +50,33 @@ public async Task CreateArmClientAsync_CreatesAndUsesCachedClient() var client = await _azureService.GetArmClientAsync(TenantName, retryPolicyArgs); var client2 = await _azureService.GetArmClientAsync(TenantName, retryPolicyArgs); - Assert.Equal(client, client2); + Assert.NotEqual(client, client2); var otherClient = await _azureService.GetArmClientAsync(tenantName2, retryPolicyArgs); Assert.NotEqual(client, otherClient); + + // Not tested: we'd like to, but can't, verify the TokenCredential is reused + // between client and client2 but NOT with otherClient. ArmClient doesn't expose + // the credential nor the HttpPipeline the credential is included within. } [Fact] - public async Task ResolveTenantIdAsync_ReturnsValueNoService() + public async Task ResolveTenantIdAsync_ReturnsNullOnNull() { - var testAzureService = new TestAzureService(null); - - string? actual = await testAzureService.ResolveTenantId(TenantName); - Assert.Equal(TenantName, actual); - - string? actual2 = await testAzureService.ResolveTenantId(null); - Assert.Null(actual2); + string? actual = await _azureService.ResolveTenantId(null, TestContext.Current.CancellationToken); + Assert.Null(actual); } [Fact] public void EscapeKqlString_EscapesSingleQuotes() { // Arrange - var testAzureService = new TestAzureService(); var input = "resource'with'quotes"; var expected = "resource''with''quotes"; // Act - var result = testAzureService.EscapeKqlStringTest(input); + var result = _azureService.EscapeKqlStringTest(input); // Assert Assert.Equal(expected, result); @@ -81,12 +86,11 @@ public void EscapeKqlString_EscapesSingleQuotes() public void EscapeKqlString_EscapesBackslashes() { // Arrange - var testAzureService = new TestAzureService(); var input = @"resource\with\backslashes"; var expected = @"resource\\with\\backslashes"; // Act - var result = testAzureService.EscapeKqlStringTest(input); + var result = _azureService.EscapeKqlStringTest(input); // Assert Assert.Equal(expected, result); @@ -96,12 +100,11 @@ public void EscapeKqlString_EscapesBackslashes() public void EscapeKqlString_EscapesBothQuotesAndBackslashes() { // Arrange - var testAzureService = new TestAzureService(); var input = @"resource\'with\'mixed"; var expected = @"resource\\''with\\''mixed"; // Act - var result = testAzureService.EscapeKqlStringTest(input); + var result = _azureService.EscapeKqlStringTest(input); // Assert Assert.Equal(expected, result); @@ -110,35 +113,58 @@ public void EscapeKqlString_EscapesBothQuotesAndBackslashes() [Fact] public void EscapeKqlString_HandlesNullAndEmptyStrings() { - // Arrange - var testAzureService = new TestAzureService(); - // Act & Assert - Assert.Equal(string.Empty, testAzureService.EscapeKqlStringTest(null!)); - Assert.Equal(string.Empty, testAzureService.EscapeKqlStringTest(string.Empty)); + Assert.Equal(string.Empty, _azureService.EscapeKqlStringTest(null!)); + Assert.Equal(string.Empty, _azureService.EscapeKqlStringTest(string.Empty)); } [Fact] public void EscapeKqlString_HandlesRegularStringsWithoutEscaping() { // Arrange - var testAzureService = new TestAzureService(); var input = "regular-resource-name"; // Act - var result = testAzureService.EscapeKqlStringTest(input); + var result = _azureService.EscapeKqlStringTest(input); // Assert Assert.Equal(input, result); } - private sealed class TestAzureService(ITenantService? tenantService = null) : BaseAzureService(tenantService) + [Fact] + public void InitializeUserAgentPolicy_UserAgentContainsTransportType() + { + // Initialize the user agent policy before creating test service + BaseAzureService.InitializeUserAgentPolicy(TransportTypes.StdIo); + TestAzureService testAzureService = new TestAzureService(_tenantService); + Assert.NotNull(testAzureService.GetUserAgent()); + Assert.Contains("azmcp-stdio", testAzureService.GetUserAgent()); + } + + [Fact] + public void InitializeUserAgentPolicy_ThrowsExceptionWhenTransportTypeIsNull() + { + var exception = Assert.Throws(() => BaseAzureService.InitializeUserAgentPolicy(null!)); + Assert.Equal("Value cannot be null. (Parameter 'transportType')", exception.Message); + } + + [Fact] + public void InitializeUserAgentPolicy_ThrowsExceptionWhenTransportTypeIsEmpty() + { + var exception = Assert.Throws(() => BaseAzureService.InitializeUserAgentPolicy(string.Empty)); + Assert.Equal("The value cannot be an empty string or composed entirely of whitespace. (Parameter 'transportType')", exception.Message); + } + + private sealed class TestAzureService(ITenantService tenantService) : BaseAzureService(tenantService) { public Task GetArmClientAsync(string? tenant = null, RetryPolicyOptions? retryPolicy = null) => CreateArmClientAsync(tenant, retryPolicy); - public Task ResolveTenantId(string? tenant) => ResolveTenantIdAsync(tenant); + // Expose the protected ResolveTenantIdAsync method for testing + public Task ResolveTenantId(string? tenant, CancellationToken cancellationToken) => ResolveTenantIdAsync(tenant, cancellationToken); public string EscapeKqlStringTest(string value) => EscapeKqlString(value); + + public string GetUserAgent() => UserAgent; } } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Caching/CacheServiceTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Caching/CacheServiceTests.cs index 151579d0b9..2aebac1721 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Caching/CacheServiceTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Caching/CacheServiceTests.cs @@ -15,7 +15,7 @@ public class CacheServiceTests public CacheServiceTests() { _memoryCache = new MemoryCache(Microsoft.Extensions.Options.Options.Create(new MemoryCacheOptions())); - _cacheService = new CacheService(_memoryCache); + _cacheService = new SingleUserCliCacheService(_memoryCache); } [Fact] @@ -27,11 +27,11 @@ public async Task SetAndGet_WithoutGroup_ShouldWorkAsExpected() string value = "test-value"; // Clear any existing cache data - await _cacheService.ClearAsync(); + await _cacheService.ClearAsync(TestContext.Current.CancellationToken); // Act - await _cacheService.SetAsync(group, key, value); - var result = await _cacheService.GetAsync(group, key); + await _cacheService.SetAsync(group, key, value, cancellationToken: TestContext.Current.CancellationToken); + var result = await _cacheService.GetAsync(group, key, cancellationToken: TestContext.Current.CancellationToken); // Assert Assert.Equal(value, result); @@ -46,11 +46,11 @@ public async Task SetAndGet_WithGroup_ShouldWorkAsExpected() string value = "test-value"; // Clear any existing cache data - await _cacheService.ClearAsync(); + await _cacheService.ClearAsync(TestContext.Current.CancellationToken); // Act - await _cacheService.SetAsync(group, key, value); - var result = await _cacheService.GetAsync(group, key); + await _cacheService.SetAsync(group, key, value, cancellationToken: TestContext.Current.CancellationToken); + var result = await _cacheService.GetAsync(group, key, cancellationToken: TestContext.Current.CancellationToken); // Assert Assert.Equal(value, result); @@ -67,12 +67,12 @@ public async Task GetGroupKeysAsync_ShouldReturnKeysInGroup() string value2 = "test-value2"; // Clear any existing cache data - await _cacheService.ClearAsync(); + await _cacheService.ClearAsync(TestContext.Current.CancellationToken); // Act - await _cacheService.SetAsync(group, key1, value1); - await _cacheService.SetAsync(group, key2, value2); - var groupKeys = await _cacheService.GetGroupKeysAsync(group); + await _cacheService.SetAsync(group, key1, value1, cancellationToken: TestContext.Current.CancellationToken); + await _cacheService.SetAsync(group, key2, value2, cancellationToken: TestContext.Current.CancellationToken); + var groupKeys = await _cacheService.GetGroupKeysAsync(group, TestContext.Current.CancellationToken); // Assert Assert.Equal(2, groupKeys.Count()); @@ -91,16 +91,18 @@ public async Task DeleteAsync_WithGroup_ShouldRemoveKeyFromGroup() string value2 = "test-value2"; // Clear any existing cache data - await _cacheService.ClearAsync(); + await _cacheService.ClearAsync(TestContext.Current.CancellationToken); // Act - await _cacheService.SetAsync(group, key1, value1); - await _cacheService.SetAsync(group, key2, value2); - await _cacheService.DeleteAsync(group, key1); + await _cacheService.SetAsync(group, key1, value1, cancellationToken: TestContext.Current.CancellationToken); + await _cacheService.SetAsync(group, key2, value2, cancellationToken: TestContext.Current.CancellationToken); + await _cacheService.DeleteAsync(group, key1, TestContext.Current.CancellationToken); - var groupKeys = await _cacheService.GetGroupKeysAsync(group); - var result1 = await _cacheService.GetAsync(group, key1); - var result2 = await _cacheService.GetAsync(group, key2); // Assert + var groupKeys = await _cacheService.GetGroupKeysAsync(group, TestContext.Current.CancellationToken); + var result1 = await _cacheService.GetAsync(group, key1, cancellationToken: TestContext.Current.CancellationToken); + var result2 = await _cacheService.GetAsync(group, key2, cancellationToken: TestContext.Current.CancellationToken); + + // Assert Assert.Single(groupKeys); Assert.Contains(key2, groupKeys); Assert.Null(result1); @@ -118,19 +120,19 @@ public async Task ClearAsync_ShouldRemoveAllCachedItems() string value2 = "test-value2"; // Clear any existing cache data first - await _cacheService.ClearAsync(); + await _cacheService.ClearAsync(TestContext.Current.CancellationToken); - await _cacheService.SetAsync(group1, key1, value1); - await _cacheService.SetAsync(group2, key2, value2); + await _cacheService.SetAsync(group1, key1, value1, cancellationToken: TestContext.Current.CancellationToken); + await _cacheService.SetAsync(group2, key2, value2, cancellationToken: TestContext.Current.CancellationToken); // Act - await _cacheService.ClearAsync(); + await _cacheService.ClearAsync(TestContext.Current.CancellationToken); // Assert - var group1Keys = await _cacheService.GetGroupKeysAsync(group1); - var group2Keys = await _cacheService.GetGroupKeysAsync(group2); - var result1 = await _cacheService.GetAsync(group1, key1); - var result2 = await _cacheService.GetAsync(group2, key2); + var group1Keys = await _cacheService.GetGroupKeysAsync(group1, TestContext.Current.CancellationToken); + var group2Keys = await _cacheService.GetGroupKeysAsync(group2, TestContext.Current.CancellationToken); + var result1 = await _cacheService.GetAsync(group1, key1, cancellationToken: TestContext.Current.CancellationToken); + var result2 = await _cacheService.GetAsync(group2, key2, cancellationToken: TestContext.Current.CancellationToken); Assert.Empty(group1Keys); Assert.Empty(group2Keys); @@ -150,19 +152,19 @@ public async Task ClearGroupAsync_ShouldRemoveOnlySpecificGroup() string value2 = "test-value2"; // Clear any existing cache data first - await _cacheService.ClearAsync(); + await _cacheService.ClearAsync(TestContext.Current.CancellationToken); - await _cacheService.SetAsync(group1, key1, value1); - await _cacheService.SetAsync(group2, key2, value2); + await _cacheService.SetAsync(group1, key1, value1, cancellationToken: TestContext.Current.CancellationToken); + await _cacheService.SetAsync(group2, key2, value2, cancellationToken: TestContext.Current.CancellationToken); // Act - await _cacheService.ClearGroupAsync(group1); + await _cacheService.ClearGroupAsync(group1, TestContext.Current.CancellationToken); // Assert - var group1Keys = await _cacheService.GetGroupKeysAsync(group1); - var group2Keys = await _cacheService.GetGroupKeysAsync(group2); - var result1 = await _cacheService.GetAsync(group1, key1); - var result2 = await _cacheService.GetAsync(group2, key2); + var group1Keys = await _cacheService.GetGroupKeysAsync(group1, TestContext.Current.CancellationToken); + var group2Keys = await _cacheService.GetGroupKeysAsync(group2, TestContext.Current.CancellationToken); + var result1 = await _cacheService.GetAsync(group1, key1, cancellationToken: TestContext.Current.CancellationToken); + var result2 = await _cacheService.GetAsync(group2, key2, cancellationToken: TestContext.Current.CancellationToken); Assert.Empty(group1Keys); Assert.Single(group2Keys); diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Http/HttpClientServiceTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Http/HttpClientServiceTests.cs index 6ffef70d02..d5ed9f40e1 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Http/HttpClientServiceTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Http/HttpClientServiceTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Azure.Mcp.Core.Areas.Server.Options; using Azure.Mcp.Core.Services.Http; using Xunit; @@ -12,7 +13,7 @@ public class HttpClientServiceTests public void Constructor_WithNullOptions_ThrowsArgumentNullException() { // Act & Assert - Assert.Throws(() => new HttpClientService(null!)); + Assert.Throws(() => new HttpClientService(null!, null!)); } [Fact] @@ -22,10 +23,9 @@ public void DefaultClient_ReturnsConfiguredHttpClient() var options = new HttpClientOptions { DefaultTimeout = TimeSpan.FromSeconds(30), - DefaultUserAgent = "TestAgent" }; var optionsWrapper = Microsoft.Extensions.Options.Options.Create(options); - using var service = new HttpClientService(optionsWrapper); + using var service = new HttpClientService(optionsWrapper, null!); // Act var client = service.DefaultClient; @@ -33,7 +33,6 @@ public void DefaultClient_ReturnsConfiguredHttpClient() // Assert Assert.NotNull(client); Assert.Equal(TimeSpan.FromSeconds(30), client.Timeout); - Assert.Contains("TestAgent", client.DefaultRequestHeaders.UserAgent.ToString()); } [Fact] @@ -42,7 +41,7 @@ public void CreateClient_WithBaseAddress_ReturnsClientWithBaseAddress() // Arrange var options = new HttpClientOptions(); var optionsWrapper = Microsoft.Extensions.Options.Options.Create(options); - using var service = new HttpClientService(optionsWrapper); + using var service = new HttpClientService(optionsWrapper, null!); var baseAddress = new Uri("https://example.com"); // Act @@ -59,7 +58,7 @@ public void CreateClient_WithConfigureAction_AppliesConfiguration() // Arrange var options = new HttpClientOptions(); var optionsWrapper = Microsoft.Extensions.Options.Options.Create(options); - using var service = new HttpClientService(optionsWrapper); + using var service = new HttpClientService(optionsWrapper, null!); var baseAddress = new Uri("https://example.com"); // Act @@ -84,7 +83,7 @@ public void CreateClient_WithProxyConfiguration_CreatesProxyEnabledClient() NoProxy = "localhost,127.0.0.1" }; var optionsWrapper = Microsoft.Extensions.Options.Options.Create(options); - using var service = new HttpClientService(optionsWrapper); + using var service = new HttpClientService(optionsWrapper, null!); // Act using var client = service.CreateClient(); @@ -101,7 +100,7 @@ public void Dispose_DisposesDefaultClient() // Arrange var options = new HttpClientOptions(); var optionsWrapper = Microsoft.Extensions.Options.Options.Create(options); - var service = new HttpClientService(optionsWrapper); + var service = new HttpClientService(optionsWrapper, null!); var client = service.DefaultClient; // Force creation // Act @@ -110,4 +109,48 @@ public void Dispose_DisposesDefaultClient() // Assert Assert.Throws(() => service.CreateClient()); } + + [Fact] + public void UserAgent_IsSetCorrectly() + { + // Arrange + var options = new HttpClientOptions(); + var optionsWrapper = Microsoft.Extensions.Options.Options.Create(options); + var serviceStartOptions = new ServiceStartOptions + { + Transport = "http" + }; + var serviceStartOptionsWrapper = Microsoft.Extensions.Options.Options.Create(serviceStartOptions); + var service = new HttpClientService(optionsWrapper, serviceStartOptionsWrapper); + var client = service.DefaultClient; + + // Act + var userAgent = client.DefaultRequestHeaders.UserAgent; + + // Assert + Assert.Contains("azmcp-http/", userAgent.ToString()); + } + + [Fact] + public void UserAgent_UserAgentFromHttpClientOptionsIsIgnored() + { + // Arrange + var options = new HttpClientOptions(); + options.DefaultUserAgent = "CustomAgent/1.0"; + var optionsWrapper = Microsoft.Extensions.Options.Options.Create(options); + var serviceStartOptions = new ServiceStartOptions + { + Transport = "http" + }; + var serviceStartOptionsWrapper = Microsoft.Extensions.Options.Options.Create(serviceStartOptions); + var service = new HttpClientService(optionsWrapper, serviceStartOptionsWrapper); + var client = service.DefaultClient; + + // Act + var userAgent = client.DefaultRequestHeaders.UserAgent; + + // Assert + Assert.Contains("azmcp-http/", userAgent.ToString()); + } + } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Telemetry/TelemetryServiceTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Telemetry/TelemetryServiceTests.cs index 2631e8865d..31437bbcc9 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Telemetry/TelemetryServiceTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Telemetry/TelemetryServiceTests.cs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Azure.Mcp.Core.Areas.Server.Options; using Azure.Mcp.Core.Configuration; using Azure.Mcp.Core.Services.Telemetry; +using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using ModelContextProtocol.Protocol; using NSubstitute; @@ -12,48 +14,56 @@ namespace Azure.Mcp.Core.UnitTests.Services.Telemetry; public class TelemetryServiceTests { + private const string TestDeviceId = "test-device-id"; + private const string TestMacAddressHash = "test-hash"; + private readonly AzureMcpServerConfiguration _testConfiguration = new() + { + Name = "TestService", + Version = "1.0.0", + IsTelemetryEnabled = true, + DisplayName = "Test Display", + RootCommandGroupName = "azmcp" + }; private readonly IOptions _mockOptions; - private readonly AzureMcpServerConfiguration _configuration; private readonly IMachineInformationProvider _mockInformationProvider; + private readonly IOptions _mockServiceOptions; + private readonly ILogger _logger; public TelemetryServiceTests() { - _configuration = new AzureMcpServerConfiguration - { - Name = "TestService", - Version = "1.0.0", - IsTelemetryEnabled = true - }; - _mockOptions = Substitute.For>(); - _mockOptions.Value.Returns(_configuration); + _mockOptions.Value.Returns(_testConfiguration); + + _mockServiceOptions = Substitute.For>(); _mockInformationProvider = Substitute.For(); - _mockInformationProvider.GetMacAddressHash().Returns(Task.FromResult("test-hash")); - _mockInformationProvider.GetOrCreateDeviceId().Returns(Task.FromResult("test-device-id")); + _mockInformationProvider.GetMacAddressHash().Returns(Task.FromResult(TestMacAddressHash)); + _mockInformationProvider.GetOrCreateDeviceId().Returns(Task.FromResult(TestDeviceId)); + + _logger = Substitute.For>(); } [Fact] - public async Task StartActivity_WhenTelemetryDisabled_ShouldReturnNull() + public void StartActivity_WhenTelemetryDisabled_ShouldReturnNull() { // Arrange - _configuration.IsTelemetryEnabled = false; - using var service = new TelemetryService(_mockInformationProvider, _mockOptions); + _testConfiguration.IsTelemetryEnabled = false; + using var service = new TelemetryService(_mockInformationProvider, _mockOptions, _mockServiceOptions, _logger); const string activityId = "test-activity"; // Act - var activity = await service.StartActivity(activityId); + var activity = service.StartActivity(activityId); // Assert Assert.Null(activity); } [Fact] - public async Task StartActivity_WithClientInfo_WhenTelemetryDisabled_ShouldReturnNull() + public void StartActivity_WithClientInfo_WhenTelemetryDisabled_ShouldReturnNull() { // Arrange - _configuration.IsTelemetryEnabled = false; - using var service = new TelemetryService(_mockInformationProvider, _mockOptions); + _testConfiguration.IsTelemetryEnabled = false; + using var service = new TelemetryService(_mockInformationProvider, _mockOptions, _mockServiceOptions, _logger); const string activityId = "test-activity"; var clientInfo = new Implementation { @@ -62,7 +72,7 @@ public async Task StartActivity_WithClientInfo_WhenTelemetryDisabled_ShouldRetur }; // Act - var activity = await service.StartActivity(activityId, clientInfo); + using var activity = service.StartActivity(activityId, clientInfo); // Assert Assert.Null(activity); @@ -72,7 +82,7 @@ public async Task StartActivity_WithClientInfo_WhenTelemetryDisabled_ShouldRetur public void Dispose_WithNullLogForwarder_ShouldNotThrow() { // Arrange - var service = new TelemetryService(_mockInformationProvider, _mockOptions); + var service = new TelemetryService(_mockInformationProvider, _mockOptions, _mockServiceOptions, _logger); // Act & Assert var exception = Record.Exception(() => service.Dispose()); @@ -83,7 +93,7 @@ public void Dispose_WithNullLogForwarder_ShouldNotThrow() public void Constructor_WithNullOptions_ShouldThrowArgumentNullException() { // Arrange, Act & Assert - Assert.Throws(() => new TelemetryService(_mockInformationProvider, null!)); + Assert.Throws(() => new TelemetryService(_mockInformationProvider, null!, _mockServiceOptions, _logger)); } [Fact] @@ -94,7 +104,41 @@ public void Constructor_WithNullConfiguration_ShouldThrowNullReferenceException( mockOptions.Value.Returns((AzureMcpServerConfiguration)null!); // Act & Assert - Assert.Throws(() => new TelemetryService(_mockInformationProvider, mockOptions)); + Assert.Throws(() => new TelemetryService(_mockInformationProvider, mockOptions, _mockServiceOptions, _logger)); + } + + [Fact] + public void GetDefaultTags_ThrowsWhenTagsNotInitialized() + { + // Arrange + _mockOptions.Value.Returns(_testConfiguration); + + // Act & Assert + var service = new TelemetryService(_mockInformationProvider, _mockOptions, _mockServiceOptions, _logger); + + Assert.Throws(() => service.GetDefaultTags()); + } + + [Fact] + public void GetDefaultTags_ReturnsEmptyOnDisabled() + { + // Arrange + _testConfiguration.IsTelemetryEnabled = false; + + var serviceStartOptions = new ServiceStartOptions + { + Mode = "test-mode", + Debug = true, + Transport = TransportTypes.StdIo + }; + _mockServiceOptions.Value.Returns(serviceStartOptions); + + // Act + var service = new TelemetryService(_mockInformationProvider, _mockOptions, _mockServiceOptions, _logger); + var tags = service.GetDefaultTags(); + + // Assert + Assert.Empty(tags); } [Theory] @@ -107,16 +151,20 @@ public async Task StartActivity_WithInvalidActivityId_ShouldHandleGracefully(str { Name = "TestService", Version = "1.0.0", - IsTelemetryEnabled = true + IsTelemetryEnabled = true, + DisplayName = "Test Display", + RootCommandGroupName = "azmcp" }; var mockOptions = Substitute.For>(); mockOptions.Value.Returns(configuration); - using var service = new TelemetryService(_mockInformationProvider, mockOptions); + using var service = new TelemetryService(_mockInformationProvider, mockOptions, _mockServiceOptions, _logger); + + await service.InitializeAsync(); // Act - var activity = await service.StartActivity(activityId); + var activity = service.StartActivity(activityId); // Assert // ActivitySource.StartActivity typically handles null/empty names gracefully @@ -126,4 +174,170 @@ public async Task StartActivity_WithInvalidActivityId_ShouldHandleGracefully(str activity.Dispose(); } } + + [Fact] + public void StartActivity_WithoutInitialization_Throws() + { + // Arrange + var configuration = new AzureMcpServerConfiguration + { + Name = "TestService", + Version = "1.0.0", + IsTelemetryEnabled = true, + DisplayName = "Test Display", + RootCommandGroupName = "azmcp" + }; + + var mockOptions = Substitute.For>(); + mockOptions.Value.Returns(configuration); + + using var service = new TelemetryService(_mockInformationProvider, mockOptions, _mockServiceOptions, _logger); + + // Act & Assert + // Test both overloads. + Assert.Throws(() => service.StartActivity("an-activity-id")); + + var clientInfo = new Implementation + { + Name = "Foo-Bar-MCP", + Version = "1.0.0", + Title = "Test MCP server" + }; + Assert.Throws(() => service.StartActivity("an-activity-id", clientInfo)); + } + + [Fact] + public async Task StartActivity_WhenInitializationFails_Throws() + { + // Arrange + var informationProvider = new ExceptionalInformationProvider(); + + var configuration = new AzureMcpServerConfiguration + { + Name = "TestService", + Version = "1.0.0", + IsTelemetryEnabled = true, + DisplayName = "Test Display", + RootCommandGroupName = "azmcp" + }; + + var mockOptions = Substitute.For>(); + mockOptions.Value.Returns(configuration); + + var clientInfo = new Implementation + { + Name = "Foo-Bar-MCP", + Version = "1.0.0", + Title = "Test MCP server" + }; + + // Act & Assert + using var service = new TelemetryService(informationProvider, mockOptions, _mockServiceOptions, _logger); + + await Assert.ThrowsAsync(() => service.InitializeAsync()); + + Assert.Throws(() => service.StartActivity("an-activity-id", clientInfo)); + } + + [Fact] + public async Task StartActivity_ReturnsActivityWhenEnabled() + { + // Arrange + var serviceStartOptions = new ServiceStartOptions + { + Mode = "test-mode", + Debug = true, + Transport = TransportTypes.StdIo + }; + _mockServiceOptions.Value.Returns(serviceStartOptions); + + var configuration = new AzureMcpServerConfiguration + { + Name = "TestService", + Version = "1.0.0", + IsTelemetryEnabled = true, + DisplayName = "Test Display", + RootCommandGroupName = "azmcp" + }; + var operationName = "an-activity-id"; + var mockOptions = Substitute.For>(); + mockOptions.Value.Returns(configuration); + + using var service = new TelemetryService(_mockInformationProvider, mockOptions, _mockServiceOptions, _logger); + + await service.InitializeAsync(); + + var defaultTags = service.GetDefaultTags(); + + // Act + var activity = service.StartActivity(operationName); + + // Assert + if (activity != null) + { + Assert.Equal(operationName, activity.OperationName); + } + + AssertDefaultTags(defaultTags, serviceStartOptions); + } + + [Fact] + public async Task InitializeAsync_InvokedOnce() + { + // Arrange + var configuration = new AzureMcpServerConfiguration + { + Name = "TestService", + Version = "1.0.0", + IsTelemetryEnabled = true, + DisplayName = "Test Display", + RootCommandGroupName = "azmcp" + }; + + var mockOptions = Substitute.For>(); + mockOptions.Value.Returns(configuration); + + using var service = new TelemetryService(_mockInformationProvider, mockOptions, _mockServiceOptions, _logger); + + await service.InitializeAsync(); + await service.InitializeAsync(); + + // Act + await _mockInformationProvider.Received(1).GetOrCreateDeviceId(); + await _mockInformationProvider.Received(1).GetMacAddressHash(); + } + + private static void AssertDefaultTags(IReadOnlyList> tags, + ServiceStartOptions? expectedServiceOptions = null) + { + var dictionary = tags.ToDictionary(); + Assert.NotEmpty(tags); + + AssertTag(dictionary, TelemetryConstants.TagName.DevDeviceId, TestDeviceId); + AssertTag(dictionary, TelemetryConstants.TagName.MacAddressHash, TestMacAddressHash); + + if (expectedServiceOptions != null) + { + Assert.NotNull(expectedServiceOptions.Mode); + AssertTag(dictionary, TelemetryConstants.TagName.ServerMode, expectedServiceOptions.Mode); + } + else + { + Assert.False(dictionary.ContainsKey(TelemetryConstants.TagName.ServerMode)); + } + } + + private static void AssertTag(IDictionary tags, string tagName, string expectedValue) + { + Assert.True(tags.ContainsKey(tagName)); + Assert.Equal(expectedValue, tags[tagName]); + } + + private class ExceptionalInformationProvider : IMachineInformationProvider + { + public Task GetMacAddressHash() => Task.FromResult("test-mac-address"); + + public Task GetOrCreateDeviceId() => Task.FromException( + new ArgumentNullException("test-exception")); + } } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Attributes/CustomMatcherAttribute.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Attributes/CustomMatcherAttribute.cs new file mode 100644 index 0000000000..bc9c2c6bb6 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Attributes/CustomMatcherAttribute.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; +using System.Threading; +using Xunit.v3; + +namespace Azure.Mcp.Tests.Client.Attributes; + +/// +/// Attribute to customize the test-proxy matcher for a specific test method. +/// Apply this to individual test methods to override default matching behavior for that test only. +/// +/// Tests other than what this is applied to will use the default matcher behavior as defined in default test configuration. +/// +public sealed class CustomMatcherAttribute : BeforeAfterTestAttribute +{ + private static readonly AsyncLocal Current = new(); + + /// + /// When true, the request/response body will be compared during playback matching. Otherwise, body comparison is skipped. Defaults to true. + /// + public bool CompareBodies { get; set; } + + /// + /// When true, query parameter ordering will be ignored during playback matching. Defaults to false. + /// + public bool IgnoreQueryOrdering { get; set; } + + public CustomMatcherAttribute( + bool compareBody = false, + bool ignoreQueryordering = false) + { + CompareBodies = compareBody; + IgnoreQueryOrdering = ignoreQueryordering; + } + + public override void Before(MethodInfo methodUnderTest, IXunitTest xunitTest) + { + base.Before(methodUnderTest, xunitTest); + Current.Value = this; + } + + public override void After(MethodInfo methodUnderTest, IXunitTest xunitTest) + { + base.After(methodUnderTest, xunitTest); + Current.Value = null; + } + + internal static CustomMatcherAttribute? GetActive() => Current.Value; +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Azure.Mcp.Tests.csproj b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Azure.Mcp.Tests.csproj index 46e4b6aa04..edac310bc5 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Azure.Mcp.Tests.csproj +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Azure.Mcp.Tests.csproj @@ -12,4 +12,4 @@ - \ No newline at end of file + diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/CommandTestsBase.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/CommandTestsBase.cs index 0c61d74f5e..f1a9d78a45 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/CommandTestsBase.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/CommandTestsBase.cs @@ -1,10 +1,12 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.ClientModel; using System.Text; using System.Text.Json; using System.Text.Json.Nodes; using Azure.Mcp.Tests.Client.Helpers; +using Azure.Mcp.Tests.Helpers; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using Xunit; @@ -15,12 +17,13 @@ public abstract class CommandTestsBase(ITestOutputHelper output) : IAsyncLifetim { protected const string TenantNameReason = "Service principals cannot use TenantName for lookup"; - protected IMcpClient Client { get; private set; } = default!; - protected LiveTestSettings Settings { get; private set; } = default!; + protected McpClient Client { get; private set; } = default!; + protected LiveTestSettings Settings { get; set; } = default!; protected StringBuilder FailureOutput { get; } = new(); protected ITestOutputHelper Output { get; } = output; - private string[]? _customArguments; + public string[]? CustomArguments; + public TestMode TestMode = TestMode.Live; /// /// Sets custom arguments for the MCP server. Call this before InitializeAsync(). @@ -28,15 +31,56 @@ public abstract class CommandTestsBase(ITestOutputHelper output) : IAsyncLifetim /// Custom arguments to pass to the server (e.g., ["server", "start", "--mode", "single"]) public void SetArguments(params string[] arguments) { - _customArguments = arguments; + CustomArguments = arguments; } public virtual async ValueTask InitializeAsync() { - // Initialize settings - var settingsFixture = new LiveTestSettingsFixture(); - await settingsFixture.InitializeAsync(); - Settings = settingsFixture.Settings; + await InitializeAsyncInternal(null); + } + + public static LiveTestSettings PlaybackSettings => new() + { + SubscriptionId = "00000000-0000-0000-0000-000000000000", + TenantId = "00000000-0000-0000-0000-000000000000", + ResourceBaseName = "Sanitized", + SubscriptionName = "Sanitized", + TenantName = "Sanitized", + TestMode = TestMode.Playback + }; + + protected virtual async ValueTask LoadSettingsAsync() + { + Settings = await TryLoadLiveSettingsAsync().ConfigureAwait(false) ?? PlaybackSettings; + + // if the user has set to playback in LiveTestSettings, they're + // intentionally checking playback mode, load the playback settings + // and ignore what we got from the .testsettings.json file + if (Settings.TestMode == TestMode.Playback) + { + Settings = PlaybackSettings; + } + + TestMode = Settings.TestMode; + } + + private async Task TryLoadLiveSettingsAsync() + { + try + { + var settingsFixture = new LiveTestSettingsFixture(); + await settingsFixture.InitializeAsync().ConfigureAwait(false); + return settingsFixture.Settings; + } + catch (FileNotFoundException) + { + return null; + } + } + + protected virtual async ValueTask InitializeAsyncInternal(TestProxyFixture? proxy = null) + { + await LoadSettingsAsync(); string executablePath = McpTestUtilities.GetAzMcpExecutablePath(); @@ -46,7 +90,26 @@ public virtual async ValueTask InitializeAsync() string[] defaultArgs = enableDebug ? ["server", "start", "--mode", "all", "--debug"] : ["server", "start", "--mode", "all"]; - var arguments = _customArguments ?? defaultArgs; + var arguments = CustomArguments ?? defaultArgs; + + Dictionary envVarDictionary = [ + // Propagate playback signaling & sanitized identifiers to server process. + + // TODO: Temporarily commenting these out until we can solve for subscription id tests + // see https://github.com/microsoft/mcp/issues/1103 + // { "AZURE_TENANT_ID", Settings.TenantId }, + // { "AZURE_SUBSCRIPTION_ID", Settings.SubscriptionId } + ]; + + if (proxy != null && proxy.Proxy != null) + { + envVarDictionary.Add("TEST_PROXY_URL", proxy.Proxy.BaseUri); + + if (TestMode is TestMode.Playback) + { + envVarDictionary.Add("AZURE_TOKEN_CREDENTIALS", "PlaybackTokenCredential"); + } + } StdioClientTransportOptions transportOptions = new() { @@ -54,7 +117,8 @@ public virtual async ValueTask InitializeAsync() Command = executablePath, Arguments = arguments, // Direct stderr to test output helper as required by task - StandardErrorLines = line => Output.WriteLine($"[MCP Server] {line}") + StandardErrorLines = line => Output.WriteLine($"[MCP Server] {line}"), + EnvironmentVariables = envVarDictionary }; if (!string.IsNullOrEmpty(Settings.TestPackage)) @@ -65,8 +129,8 @@ public virtual async ValueTask InitializeAsync() } var clientTransport = new StdioClientTransport(transportOptions); - Client = await McpClientFactory.CreateAsync(clientTransport); - + Output.WriteLine("Attempting to start MCP Client"); + Client = await McpClient.CreateAsync(clientTransport); Output.WriteLine("MCP client initialized successfully"); } @@ -75,7 +139,7 @@ public virtual async ValueTask InitializeAsync() return CallToolAsync(command, parameters, Client); } - protected async Task CallToolAsync(string command, Dictionary parameters, IMcpClient mcpClient) + protected async Task CallToolAsync(string command, Dictionary parameters, McpClient mcpClient) { // Use the same debug logic as MCP server initialization var debugEnvVar = Environment.GetEnvironmentVariable("AZURE_MCP_TEST_DEBUG"); @@ -97,15 +161,6 @@ public virtual async ValueTask InitializeAsync() { // MCP client throws exceptions for error responses, but we want to handle them gracefully writeOutput($"MCP exception: {ex.Message}"); - - // For validation errors, we'll return a synthetic error response - if (ex.Message.Contains("An error occurred")) - { - // Return null to indicate error response (no results) - writeOutput("synthetic error response: null (error response)"); - return null; - } - throw; // Re-throw if we can't handle it } @@ -146,7 +201,7 @@ public void Dispose() GC.SuppressFinalize(this); } - public async ValueTask DisposeAsync() + public virtual async ValueTask DisposeAsync() { await DisposeAsyncCore().ConfigureAwait(false); Dispose(disposing: false); diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/BinaryContentHelper.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/BinaryContentHelper.cs new file mode 100644 index 0000000000..2ccfc4e1b6 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/BinaryContentHelper.cs @@ -0,0 +1,35 @@ +using System.ClientModel; +using System.Text.Json; + +namespace Azure.Mcp.Tests.Client.Helpers; + +/// +/// Generate BinaryContent from objects or JSON strings. +/// +internal static class BinaryContentHelper +{ + private static readonly JsonSerializerOptions _defaultJsonOptions = new() + { + WriteIndented = false + }; + + /// + /// Serialize object to JSON UTF8 bytes and wrap into BinaryContent via BinaryData factory. + /// Avoid generic Create which expects IPersistableModel. + /// + public static BinaryContent FromObject(T value, JsonSerializerOptions? jsonOptions = null) + { + if (value is null) + { + return BinaryContent.Create(BinaryData.FromString("null")); + } + var bytes = JsonSerializer.SerializeToUtf8Bytes(value, jsonOptions ?? _defaultJsonOptions); + return BinaryContent.Create(new BinaryData(bytes)); + } + + public static BinaryContent FromDictionary(IDictionary dict, JsonSerializerOptions? jsonOptions = null) + => FromObject(dict, jsonOptions); + + public static BinaryContent FromJsonString(string json) + => BinaryContent.Create(BinaryData.FromString(string.IsNullOrEmpty(json) ? "null" : json)); +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/LiveTestSettings.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/LiveTestSettings.cs index 74c188634a..20a8f9269f 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/LiveTestSettings.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/LiveTestSettings.cs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Azure.Mcp.Tests.Helpers; + namespace Azure.Mcp.Tests.Client.Helpers; public class LiveTestSettings @@ -15,6 +17,8 @@ public class LiveTestSettings public string ResourceBaseName { get; set; } = string.Empty; public string SettingsDirectory { get; set; } = string.Empty; public string TestPackage { get; set; } = string.Empty; + public TestMode TestMode { get; set; } = TestMode.Live; public bool DebugOutput { get; set; } - public Dictionary DeploymentOutputs { get; set; } = new(); + public Dictionary DeploymentOutputs { get; set; } = []; + public Dictionary EnvironmentVariables { get; set; } = []; } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/LiveTestSettingsFixture.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/LiveTestSettingsFixture.cs index a9e60b88d4..98c4bc526d 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/LiveTestSettingsFixture.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/LiveTestSettingsFixture.cs @@ -16,6 +16,7 @@ public virtual async ValueTask InitializeAsync() { var testSettingsFileName = ".testsettings.json"; var directory = Path.GetDirectoryName(typeof(LiveTestSettingsFixture).Assembly.Location); + while (!string.IsNullOrEmpty(directory)) { var testSettingsFilePath = Path.Combine(directory, testSettingsFileName); @@ -23,9 +24,20 @@ public virtual async ValueTask InitializeAsync() { var content = await File.ReadAllTextAsync(testSettingsFilePath); - Settings = JsonSerializer.Deserialize(content) + var options = new JsonSerializerOptions + { + PropertyNameCaseInsensitive = true, + Converters = { new System.Text.Json.Serialization.JsonStringEnumConverter() } + }; + + Settings = JsonSerializer.Deserialize(content, options) ?? throw new Exception("Unable to deserialize live test settings"); + foreach (var (key, value) in Settings.EnvironmentVariables) + { + Environment.SetEnvironmentVariable(key, value); + } + Settings.SettingsDirectory = directory; await SetPrincipalSettingsAsync(); @@ -42,7 +54,7 @@ private async Task SetPrincipalSettingsAsync() { const string GraphScopeUri = "https://graph.microsoft.com/.default"; var credential = new CustomChainedCredential(Settings.TenantId); - AccessToken token = await credential.GetTokenAsync(new TokenRequestContext([GraphScopeUri]), CancellationToken.None); + AccessToken token = await credential.GetTokenAsync(new TokenRequestContext([GraphScopeUri]), TestContext.Current.CancellationToken); var jsonToken = new JwtSecurityToken(token.Token); var claims = JsonSerializer.Serialize(jsonToken.Claims.Select(x => x.Type)); diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/PlaybackAwareTokenCredentialProvider.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/PlaybackAwareTokenCredentialProvider.cs new file mode 100644 index 0000000000..b4dbeb7848 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/PlaybackAwareTokenCredentialProvider.cs @@ -0,0 +1,38 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Threading; +using System.Threading.Tasks; +using Azure.Core; +using Azure.Mcp.Core.Services.Azure.Authentication; +using Azure.Mcp.Tests.Helpers; +using Microsoft.Extensions.Logging; + +namespace Azure.Mcp.Tests.Client.Helpers; + +public sealed class PlaybackAwareTokenCredentialProvider : IAzureTokenCredentialProvider +{ + private readonly Func _testModeAccessor; + private readonly ILoggerFactory _loggerFactory; + private readonly TokenCredential _playbackCredential = new PlaybackTokenCredential(); + private readonly Lazy _liveProvider; + + public PlaybackAwareTokenCredentialProvider(Func testModeAccessor, ILoggerFactory loggerFactory) + { + ArgumentNullException.ThrowIfNull(testModeAccessor); + _testModeAccessor = testModeAccessor; + _loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); + _liveProvider = new Lazy(() => new SingleIdentityTokenCredentialProvider(_loggerFactory)); + } + + public Task GetTokenCredentialAsync(string? tenantId, CancellationToken cancellation) + { + if (_testModeAccessor() == TestMode.Playback) + { + return Task.FromResult(_playbackCredential); + } + + return _liveProvider.Value.GetTokenCredentialAsync(tenantId, cancellation); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/RecordingPathResolver.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/RecordingPathResolver.cs new file mode 100644 index 0000000000..a4316b6ac0 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/RecordingPathResolver.cs @@ -0,0 +1,138 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Reflection; + +namespace Azure.Mcp.Tests.Client.Helpers; + +/// +/// Provides path resolution for session records and related assets. +/// +public sealed class RecordingPathResolver +{ + private static readonly char[] _invalidChars = ['\\', '/', ':', '*', '?', '"', '<', '>', '|']; + + private readonly string _repoRoot; + + public RecordingPathResolver() + { + _repoRoot = ResolveRepositoryRoot() ?? Directory.GetCurrentDirectory(); + } + + /// + /// Attempt to locate the repository root by walking up until a .git directory/file or global.json is found. + /// + private static string? ResolveRepositoryRoot() + { + var dir = new DirectoryInfo(Assembly.GetExecutingAssembly().Location).Parent; + while (dir != null) + { + if (Directory.Exists(Path.Combine(dir.FullName, ".git")) || + File.Exists(Path.Combine(dir.FullName, ".git")) || + File.Exists(Path.Combine(dir.FullName, "global.json"))) + { + return dir.FullName; + } + dir = dir.Parent; + } + throw new InvalidOperationException("Unable to locate repository root. Ensure tests are running in a cloned repository."); + } + + public string RepositoryRoot => _repoRoot; + + /// + /// Sanitizes a test display/name into a file-system friendly component. + /// + public static string Sanitize(string name) + { + if (string.IsNullOrWhiteSpace(name)) + return "(unknown)"; + Span buffer = stackalloc char[name.Length]; + int i = 0; + foreach (var c in name) + { + buffer[i++] = _invalidChars.Contains(c) ? '_' : c; + } + return new string(buffer); + } + + /// + /// Builds the session directory path: /SessionRecords/ + /// Example: tools/Azure.Mcp.Tools.KeyVault/tests/Azure.Mcp.Tools.KeyVault.LiveTests/SessionRecords/RecordedKeyVaultCommandTests + /// + public string GetSessionDirectory(Type testType, string? variantSuffix = null) + { + // Locate the test project directory by ascending from the assembly location until a matching *.csproj exists. + var projectDir = GetProjectDirectory(testType); + + // Compute relative path from repo root. + var relativeProjectPath = Path.GetRelativePath(_repoRoot, projectDir) + .Replace('\\', '/'); // Normalize separators for consistency. + + // Append SessionRecords and suffix. + var sessionDir = Path.Combine(relativeProjectPath, "SessionRecords") + .Replace('\\', '/'); + + // TODO: Consider caching projectDir per assembly for performance if needed. + return sessionDir; + } + + private static string GetProjectDirectory(Type testType) + { + // Locate the test project directory by ascending from the assembly location until a matching *.csproj exists. + var assemblyDir = Path.GetDirectoryName(testType.Assembly.Location)!; + var projectDir = FindProjectDirectory(assemblyDir, testType); + + return projectDir; + } + + private static string FindProjectDirectory(string startDirectory, Type testType) + { + var current = new DirectoryInfo(startDirectory); + var expectedProjectName = testType.Assembly.GetName().Name; // Typically matches .csproj file name. + + while (current != null) + { + // Look for any .csproj; prefer one matching assembly name. + var csprojFiles = current.GetFiles("*.csproj", SearchOption.TopDirectoryOnly); + if (csprojFiles.Length > 0) + { + var matching = csprojFiles.FirstOrDefault(f => Path.GetFileNameWithoutExtension(f.Name) == expectedProjectName); + return (matching ?? csprojFiles.First()).Directory!.FullName; + } + current = current.Parent; + } + + throw new InvalidOperationException($"Unable to locate project directory for test type {testType.FullName} starting from {startDirectory}."); + } + + /// + /// Builds a deterministic file name from sanitized test name. + /// TODO: Add version qualifier / async suffix when those concepts are introduced. + /// + public static string BuildFileName(string sanitizedDisplayName, bool isAsync, string? versionQualifier = null) + { + var versionPart = string.IsNullOrWhiteSpace(versionQualifier) ? string.Empty : $"[{versionQualifier}]"; // TODO: provide real version qualifier + var asyncPart = isAsync ? "Async" : string.Empty; // TODO: This is literally looking at the test name. Probably not good enough. + return $"{sanitizedDisplayName}{versionPart}{asyncPart}.json"; + } + + /// + /// Attempts to find a nearest assets.json walking upwards. + /// + public string? GetAssetsJson(Type testType) + { + var projectDir = GetProjectDirectory(testType); + + var current = new DirectoryInfo(projectDir); + + var assetsFile = Path.Combine(current.FullName, "assets.json"); + + if (File.Exists(assetsFile)) + { + return assetsFile; + } + + return null; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/TestProxyFixture.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/TestProxyFixture.cs new file mode 100644 index 0000000000..3927ceef12 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/Helpers/TestProxyFixture.cs @@ -0,0 +1,65 @@ +using System.Reflection; +using Azure.Mcp.Tests.Helpers; +using Xunit; + +namespace Azure.Mcp.Tests.Client.Helpers +{ + /// + /// xUnit fixture that runs once per test class (or collection if used via [CollectionDefinition]). + /// Provides optional access to a shared TestProxy via Proxy property if tests need it later. + /// + public sealed class TestProxyFixture : IAsyncLifetime + { + public static string DetermineRepositoryRoot() + { + var path = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? Environment.CurrentDirectory; + while (!string.IsNullOrEmpty(path)) + { + // we look for both directory and file because depending on user git config the .git may be a file instead of a directory + if (Directory.Exists(Path.Combine(path, ".git")) || File.Exists(Path.Combine(path, ".git"))) + return path; + var parent = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(parent) || parent == path) + break; + path = parent; + } + return Environment.CurrentDirectory; + } + + /// + /// Proxy instance created lazily. RecordedCommandTestsBase will start it after determining TestMode from LiveTestSettings. + /// + public TestProxy? Proxy { get; private set; } + + public ValueTask InitializeAsync() + { + return ValueTask.CompletedTask; + } + + public async Task StartProxyAsync() + { + var root = DetermineRepositoryRoot(); + Proxy = new TestProxy(); + await Proxy.Start(root); + } + + public ValueTask DisposeAsync() + { + if (Proxy is not null) + { + Proxy.Dispose(); + } + return ValueTask.CompletedTask; + } + + public Uri? GetProxyUri() + { + if (Proxy?.BaseUri is string proxyUrl && Uri.TryCreate(proxyUrl, UriKind.Absolute, out var proxyUri)) + { + return proxyUri; + } + + return null; + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/RecordedCommandTestsBase.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/RecordedCommandTestsBase.cs new file mode 100644 index 0000000000..401a34f861 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/RecordedCommandTestsBase.cs @@ -0,0 +1,410 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Threading; +using Azure.Mcp.Tests.Client.Attributes; +using Azure.Mcp.Tests.Client.Helpers; +using Azure.Mcp.Tests.Generated.Models; +using Azure.Mcp.Tests.Helpers; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using Xunit; +using Xunit.Sdk; + +namespace Azure.Mcp.Tests.Client; + +public abstract class RecordedCommandTestsBase(ITestOutputHelper output, TestProxyFixture fixture) : CommandTestsBase(output), IClassFixture +{ + private const string EmptyGuid = "00000000-0000-0000-0000-000000000000"; + + protected TestProxy? Proxy { get; private set; } = fixture.Proxy; + + protected string RecordingId { get; private set; } = string.Empty; + + /// + /// When true, a set of default "additional" sanitizers will be registered. Currently includes: + /// - Sanitize out value of ResourceBaseName from LiveTestSettings as a GeneralRegexSanitizer + /// + public virtual bool EnableDefaultSanitizerAdditions { get; set; } = true; + + /// + /// Sanitizers that will apply generally across all parts (URI, Body, HeaderValues) of the request/response. This sanitization is applied to to recorded data at rest and during recording, and against test requests during playback. + /// + public virtual List GeneralRegexSanitizers { get; } = new(); + + /// + /// Sanitizers that will apply a regex to specific headers. This sanitization is applied to to recorded data at rest and during recording, and against test requests during playback. + /// + public virtual List HeaderRegexSanitizers { get; } = new() + { + // Sanitize the WWW-Authenticate header which may contain tenant IDs or resource URLs to "Sanitized" + // During conversion to recordings, the actual tenant ID is captured in group 1 and replaced with a fixed GUID. + // REMOVAL of this formatting cause complete failure on tool side when it expects a valid URL with a GUID tenant ID. + // Hence the more complex replacement rather than a simple static string replace of the entire header value with `Sanitized` + new HeaderRegexSanitizer(new HeaderRegexSanitizerBody("WWW-Authenticate") + { + Regex = "https://login.microsoftonline.com/(.*?)\"", + GroupForReplace = "1", + Value = EmptyGuid + }) + }; + + /// + /// Sanitizers that apply a regex replacement to URIs. This sanitization is applied to to recorded data at rest and during recording, and against test requests during playback. + /// + public virtual List UriRegexSanitizers { get; } = new(); + + /// + /// Sanitizers that will apply a regex replacement to a specific json body key. This sanitization is applied to to recorded data at rest and during recording, and against test requests during playback. + /// + public virtual List BodyKeySanitizers { get; } = new(); + + /// + /// Sanitizers that will apply regex replacement to the body of requests/responses. This sanitization is applied to to recorded data at rest and during recording, and against test requests during playback. + /// + public virtual List BodyRegexSanitizers { get; } = new(); + + /// + /// The test-proxy has a default set of ~90 sanitizers for common sensitive data (GUIDs, tokens, timestamps, etc). This list allows opting out of specific default sanitizers by name. + /// Grab the names from the test-proxy source at https://github.com/Azure/azure-sdk-tools/blob/main/tools/test-proxy/Azure.Sdk.Tools.TestProxy/Common/SanitizerDictionary.cs#L65) + /// Default Set: + /// - `AZSDK3430`: `$..id` + /// + public virtual List DisabledDefaultSanitizers { get; } = new() { "AZSDK3430" }; + + /// + /// During recording, variables saved to this dictionary will be propagated to the test-proxy and saved in the recording file. + /// During playback, these variables will be available within the test function body, and can be used to ensure that dynamic values from the recording are used where + /// specific values should be used. + /// + protected readonly Dictionary TestVariables = new Dictionary(); + + /// + /// When set, applies a custom matcher for _all_ playback tests from this test class. This can be overridden on a per-test basis using the attribute on test methods. + /// + public virtual CustomDefaultMatcher? TestMatcher { get; set; } = null; + + public virtual void RegisterVariable(string name, string value) + { + if (TestMode == TestMode.Playback) + { + // no-op in live/playback modes, as during playback the variables will be populated from the recording file automatically. + return; + } + + TestVariables[name] = value; + } + + // used to resolve a recording "path" given an invoking test + protected static readonly RecordingPathResolver PathResolver = new(); + + protected virtual bool IsAsync => false; + + // todo: use this when we have versioned tests to run this against. + protected virtual string? VersionQualifier => null; + + protected override async ValueTask LoadSettingsAsync() + { + await base.LoadSettingsAsync(); + } + + public override async ValueTask InitializeAsync() + { + // load settings first to determine test mode + await LoadSettingsAsync(); + + if (fixture.Proxy == null) + { + // start the proxy if needed + await StartProxyAsync(fixture); + } + + // start MCP client with proxy URL available + await base.InitializeAsyncInternal(fixture); + + // start recording/playback session + await StartRecordOrPlayback(); + + // apply custom matcher if test has attribute + await ApplyAttributeMatcherSettings(); + } + + private async Task ApplyAttributeMatcherSettings() + { + if (Proxy == null || TestMode != TestMode.Playback) + { + return; + } + + var attr = CustomMatcherAttribute.GetActive(); + if (attr == null) + { + return; + } + + var matcher = new CustomDefaultMatcher + { + IgnoreQueryOrdering = attr.IgnoreQueryOrdering, + CompareBodies = attr.CompareBodies, + }; + + await SetMatcher(matcher, RecordingId); + } + + private async Task SetMatcher(CustomDefaultMatcher matcher, string? recordingId = null) + { + if (Proxy == null) + { + throw new InvalidOperationException("Test proxy is not initialized. Cannot set a matcher for an uninitialized test proxy."); + } + + var matcherSb = new StringBuilder(); + matcherSb.Append($"CompareBodies={matcher.CompareBodies}, IgnoreQueryOrdering={matcher.IgnoreQueryOrdering}"); + if (!string.IsNullOrEmpty(matcher.IgnoredHeaders)) + { + matcherSb.Append($", IgnoredHeaders={matcher.IgnoredHeaders}"); + } + if (!string.IsNullOrEmpty(matcher.ExcludedHeaders)) + { + matcherSb.Append($", ExcludedHeaders={matcher.ExcludedHeaders}."); + } + + // per-test matcher setting + if (recordingId != null) + { + var options = new RequestOptions(); + options.AddHeader("x-recording-id", recordingId); + + Output.WriteLine($"Applying custom matcher to recordingId \"{recordingId}\": {matcherSb}"); + await Proxy.AdminClient.SetMatcherAsync("CustomDefaultMatcher", matcher, options); + } + // global matcher setting + else + { + Output.WriteLine($"Applying custom matcher to global settings: {matcherSb}"); + await Proxy.AdminClient.SetMatcherAsync("CustomDefaultMatcher", matcher); + } + } + + public async Task StartProxyAsync(TestProxyFixture fixture) + { + // we will use the same proxy instance throughout the test class instances, so we only need to start it if not already started. + if (TestMode is TestMode.Record or TestMode.Playback && fixture.Proxy == null) + { + await fixture.StartProxyAsync(); + Proxy = fixture.Proxy; + + // onetime on starting the proxy, we have initialized the livetest settings so lets add some additional sanitizers by default + if (EnableDefaultSanitizerAdditions) + { + PopulateDefaultSanitizers(); + } + + // onetime registration of default sanitizers + // and deregistering default sanitizers that we don't want + if (Proxy != null) + { + await DisableSanitizersAsync(); + await ApplySanitizersAsync(); + + // set session matcher for this class if specified + if (TestMatcher != null) + { + await SetMatcher(TestMatcher); + } + } + } + } + + private void PopulateDefaultSanitizers() + { + if (EnableDefaultSanitizerAdditions) + { + // Sanitize out the resource basename by default! + // This implies that tests shouldn't use this baseresourcename as part of their validation logic, as sanitization will replace it with "Sanitized" and cause confusion. + GeneralRegexSanitizers.Add(new GeneralRegexSanitizer(new GeneralRegexSanitizerBody() + { + Regex = Settings.ResourceBaseName, + Value = "Sanitized", + })); + GeneralRegexSanitizers.Add(new GeneralRegexSanitizer(new GeneralRegexSanitizerBody() + { + Regex = Settings.SubscriptionId, + Value = EmptyGuid, + })); + } + } + + private async Task DisableSanitizersAsync() + { + if (DisabledDefaultSanitizers.Count > 0) + { + var toRemove = new SanitizerList(new List()); + foreach (var sanitizer in DisabledDefaultSanitizers) + { + toRemove.Sanitizers.Add(sanitizer); + } + await Proxy!.AdminClient.RemoveSanitizersAsync(toRemove); + } + } + + private async Task ApplySanitizersAsync() + { + List sanitizers = new(); + + sanitizers.AddRange(GeneralRegexSanitizers); + sanitizers.AddRange(BodyRegexSanitizers); + sanitizers.AddRange(HeaderRegexSanitizers); + sanitizers.AddRange(UriRegexSanitizers); + sanitizers.AddRange(BodyKeySanitizers); + + if (sanitizers.Count > 0) + { + await Proxy!.AdminClient.AddSanitizersAsync(sanitizers); + } + } + + public override async ValueTask DisposeAsync() + { + await StopRecordOrPlayback(); + + // On test failure, append proxy stderr for diagnostics. + if (TestContext.Current?.TestState?.Result == TestResult.Failed && Proxy != null) + { + var stderr = Proxy.SnapshotStdErr(); + if (!string.IsNullOrWhiteSpace(stderr)) + { + Output.WriteLine("=== Test Proxy stderr (captured) ==="); + Output.WriteLine(stderr); + Output.WriteLine("=== End Test Proxy stderr ==="); + } + } + + await base.DisposeAsync(); + } + + private async Task StartRecordOrPlayback() + { + if (TestMode == TestMode.Live) + { + return; + } + + if (Proxy == null) + { + throw new InvalidOperationException("Test proxy is not initialized."); + } + + var testName = TryGetCurrentTestName(); + var pathToRecording = GetSessionFilePath(testName); + var assetsPath = PathResolver.GetAssetsJson(GetType()); + + var recordOptions = new Dictionary + { + { "x-recording-file", pathToRecording }, + }; + + if (!string.IsNullOrWhiteSpace(assetsPath)) + { + recordOptions["x-recording-assets-file"] = assetsPath; + } + var bodyContent = BinaryContentHelper.FromObject(recordOptions); + + if (TestMode == TestMode.Playback) + { + Output.WriteLine($"[Playback] Session file: {pathToRecording}"); + try + { + ClientResult>? playbackResult = await Proxy.Client.StartPlaybackAsync(new TestProxyStartInformation(pathToRecording, assetsPath, null)).ConfigureAwait(false); + + // Extract recording ID from response header + if (playbackResult.GetRawResponse().Headers.TryGetValue("x-recording-id", out var recordingId)) + { + RecordingId = recordingId ?? string.Empty; + Output.WriteLine($"[Playback] Recording ID: {RecordingId}"); + } + + foreach (var key in playbackResult.Value.Keys) + { + Output.WriteLine($"[Playback] Variable from recording: {key} = {playbackResult.Value[key]}"); + TestVariables[key] = playbackResult.Value[key]; + } + } + catch (Exception e) + { + Output.WriteLine(Proxy.SnapshotStdErr() ?? $"Proxy is null while attempting to snapshot stderr. Facing exception during start playback.{e.ToString()}"); + throw; + } + } + else if (TestMode == TestMode.Record) + { + Output.WriteLine($"[Record] Session file: {pathToRecording}"); + try + { + ClientResult result = Proxy.Client.StartRecord(bodyContent); + + // Extract recording ID from response header + if (result.GetRawResponse().Headers.TryGetValue("x-recording-id", out var recordingId)) + { + RecordingId = recordingId ?? string.Empty; + Output.WriteLine($"[Record] Recording ID: {RecordingId}"); + } + } + catch (Exception e) + { + Output.WriteLine(Proxy.SnapshotStdErr() ?? $"Proxy is null while attempting to snapshot stderr. Facing exception during start record.{e.ToString()}"); + throw; + } + } + + await Task.CompletedTask; + } + + private async Task StopRecordOrPlayback() + { + if (TestMode is TestMode.Live) + { + return; + } + + if (Proxy == null) + { + throw new InvalidOperationException("Test proxy is not initialized."); + } + + if (TestMode == TestMode.Playback) + { + await Proxy.Client.StopPlaybackAsync("placeholder-ignore").ConfigureAwait(false); + } + else if (TestMode == TestMode.Record) + { + Proxy.Client.StopRecord("placeholder-ignore", TestVariables); + } + await Task.CompletedTask; + } + + private static string TryGetCurrentTestName() + { + var name = TestContext.Current?.Test?.TestCase.TestCaseDisplayName; + if (string.IsNullOrWhiteSpace(name)) + { + throw new InvalidOperationException("Test name is not available. Recording requires a valid test name."); + } + return name; + } + + private string GetSessionFilePath(string displayName) + { + var sanitized = RecordingPathResolver.Sanitize(displayName); + var dir = PathResolver.GetSessionDirectory(GetType(), variantSuffix: null); + var fileName = RecordingPathResolver.BuildFileName(sanitized, IsAsync, VersionQualifier); + var fullPath = Path.Combine(dir, fileName).Replace('\\', '/'); + return fullPath; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/TestProxy.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/TestProxy.cs new file mode 100644 index 0000000000..702ea80981 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Client/TestProxy.cs @@ -0,0 +1,395 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics; +using System.Formats.Tar; +using System.IO.Compression; +using System.Reflection; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Azure.Mcp.Tests.Generated; + +namespace Azure.Mcp.Tests.Client; + +/// +/// Lightweight test-proxy process manager used per test class to start/stop the Azure SDK test proxy. +/// This version intentionally avoids dependencies on prior internal abstractions that were missing +/// (e.g. TestEnvironment / ProcessTracker) while still providing stderr/stdout capture for failed tests. +/// +public sealed class TestProxy(bool debug = false) : IDisposable +{ + private readonly bool _debug = debug; + public StringBuilder stderr = new(); + public readonly StringBuilder stdout = new(); + private Process? _process; + private CancellationTokenSource? _cts; + private int? _httpPort; + private bool _disposed; + + public string BaseUri => _httpPort is int p ? $"http://127.0.0.1:{p}/" : throw new InvalidOperationException("Proxy not started"); + + public TestProxyClient Client { get; private set; } = default!; + public TestProxyAdminClient AdminClient { get; private set; } = default!; + + private static string? _cachedRootDir; + private static string? _cachedExecutable; + private static string? _cachedVersion; + + /// + /// In-process synchronization lock to avoid proxy exe mismanagement. + /// + private static readonly SemaphoreSlim s_downloadLock = new(1, 1); + + private async Task _getClient() + { + if (_cachedExecutable != null) + { + return _cachedExecutable; + } + + await s_downloadLock.WaitAsync(); + FileStream? lockStream = null; + try + { + var proxyDir = GetProxyDirectory(); + lockStream = await AcquireDownloadLockAsync(proxyDir).ConfigureAwait(false); + + if (_cachedExecutable != null) + { + return _cachedExecutable; + } + + var version = GetTargetVersion(); + + if (CheckProxyVersion(proxyDir, version)) + { + _cachedExecutable = FindExecutableInDirectory(proxyDir); + return _cachedExecutable; + } + + var assetName = GetAssetNameForPlatform(); + var url = $"https://github.com/Azure/azure-sdk-tools/releases/download/Azure.Sdk.Tools.TestProxy_{version}/{assetName}"; + var downloadPath = Path.Combine(proxyDir, assetName); + if (!File.Exists(downloadPath)) + { + using var client = new HttpClient(); + var bytes = await client.GetByteArrayAsync(url); + await File.WriteAllBytesAsync(downloadPath, bytes); + // record the downloaded version right here so we don't need to parse anything other than what + // is in this folder later + await File.WriteAllBytesAsync(Path.Combine(proxyDir, "version.txt"), Encoding.UTF8.GetBytes(version)); + } + + // if we've gotten to here then we need to decompress + if (assetName.EndsWith(".tar.gz")) + { + await using var compressedStream = File.OpenRead(downloadPath); + using var gzipStream = new GZipStream(compressedStream, CompressionMode.Decompress, leaveOpen: false); + TarFile.ExtractToDirectory(gzipStream, proxyDir, overwriteFiles: true); + } + else + { + ZipFile.ExtractToDirectory(downloadPath, proxyDir, overwriteFiles: true); + } + + _cachedExecutable = FindExecutableInDirectory(proxyDir); + } + finally + { + lockStream?.Dispose(); + s_downloadLock.Release(); + } + + return _cachedExecutable; + } + + /// + /// Multiple test assemblies are likely to be running in the same process due to MCP repo's usage of dotnet test + /// + /// This can lead to race conditions on making the proxy exe available on disk. To avoid this, we use a semaphore slim + /// to maintain in-process synchronization, and a file lock to maintain cross-process synchronization. + /// + /// + /// + private static async Task AcquireDownloadLockAsync(string proxyDirectory) + { + var lockPath = Path.Combine(proxyDirectory, ".download.lock"); + + while (true) + { + try + { + return new FileStream(lockPath, FileMode.OpenOrCreate, FileAccess.ReadWrite, FileShare.None, bufferSize: 1, FileOptions.DeleteOnClose); + } + catch (IOException) + { + await Task.Delay(200).ConfigureAwait(false); + } + } + } + + private bool CheckProxyVersion(string proxyDirectory, string version) + { + var versionFilePath = Path.Combine(proxyDirectory, "version.txt"); + if (File.Exists(versionFilePath)) + { + var existingVersion = File.ReadAllText(versionFilePath).Trim(); + if (existingVersion == version) + { + return true; + } + } + return false; + } + + private string GetAssetNameForPlatform() + { + var arch = RuntimeInformation.ProcessArchitecture; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return (arch == Architecture.Arm64 ? "test-proxy-standalone-win-arm64.zip" : "test-proxy-standalone-win-x64.zip"); + } + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + return (arch == Architecture.Arm64 ? "test-proxy-standalone-osx-arm64.zip" : "test-proxy-standalone-osx-x64.zip"); + } + return (arch == Architecture.Arm64 ? "test-proxy-standalone-linux-arm64.tar.gz" : "test-proxy-standalone-linux-x64.tar.gz"); + } + + private string FindExecutableInDirectory(string dir) + { + var exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "Azure.Sdk.Tools.TestProxy.exe" : "Azure.Sdk.Tools.TestProxy"; + foreach (var file in Directory.EnumerateFiles(dir, exeName, SearchOption.AllDirectories)) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + EnsureExecutable(file); + } + return file; + } + throw new FileNotFoundException($"Could not find {exeName} in {dir}"); + } + + private void EnsureExecutable(string path) + { + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + return; + } + + var mode = File.GetUnixFileMode(path); + if (!mode.HasFlag(UnixFileMode.UserExecute)) + { + File.SetUnixFileMode(path, mode | UnixFileMode.UserExecute | UnixFileMode.GroupExecute | UnixFileMode.OtherExecute); + } + } + + private string GetRootDirectory() + { + if (_cachedRootDir != null) + { + return _cachedRootDir; + } + var current = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? Directory.GetCurrentDirectory(); + while (current != null) + { + var gitPath = Path.Combine(current, ".git"); + if (File.Exists(gitPath) || Directory.Exists(gitPath)) + { + _cachedRootDir = current; + return _cachedRootDir; + } + current = Directory.GetParent(current)?.FullName; + } + + throw new InvalidOperationException("Could not find repository root (.git)"); + } + + private string GetTargetVersion() + { + if (_cachedVersion != null) + { + return _cachedVersion; + } + + var versionFile = Path.Combine(GetRootDirectory(), "eng", "common", "testproxy", "target_version.txt"); + if (!File.Exists(versionFile)) + { + throw new FileNotFoundException($"Test proxy version file not found: {versionFile}"); + } + _cachedVersion = File.ReadAllText(versionFile).Trim(); + return _cachedVersion; + } + + private string GetProxyDirectory() + { + var root = GetRootDirectory(); + var proxyDirectory = Path.Combine(root, ".proxy"); + if (!Directory.Exists(proxyDirectory)) + { + Directory.CreateDirectory(proxyDirectory); + } + return proxyDirectory; + } + + private string? GetExecutableFromAssetsDirectory() + { + var proxyDir = GetProxyDirectory(); + var toolDir = Path.Combine(proxyDir, "Azure.Sdk.Tools.TestProxy"); + + if (!Directory.Exists(toolDir)) + return null; + + var exeName = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "test-proxy.exe" : "test-proxy"; + foreach (var file in Directory.EnumerateFiles(toolDir, exeName, SearchOption.AllDirectories)) + { + if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) + { + EnsureExecutable(file); + } + return file; + } + + return null; + } + + public async Task Start(string repositoryRoot) + { + if (_process != null) + { + return; + } + + var proxyExe = GetExecutableFromAssetsDirectory() ?? await _getClient(); + + if (string.IsNullOrWhiteSpace(proxyExe) || !File.Exists(proxyExe)) + { + throw new InvalidOperationException("Unable to locate test-proxy executable."); + } + + var storageLocation = Environment.GetEnvironmentVariable("TEST_PROXY_STORAGE") ?? repositoryRoot; + var args = $"start --http-proxy --storage-location=\"{storageLocation}\""; + + ProcessStartInfo psi = new(proxyExe, args); + psi.RedirectStandardOutput = true; + psi.RedirectStandardError = true; + psi.UseShellExecute = false; + psi.EnvironmentVariables["ASPNETCORE_URLS"] = "http://127.0.0.1:0"; // Let proxy choose free port + + _process = Process.Start(psi); + + if (_process == null) + { + throw new InvalidOperationException("Failed to start test proxy process."); + } + _cts = new CancellationTokenSource(); + _ = Task.Run(() => _pumpAsync(_process.StandardError, stderr, _cts.Token)); + _ = Task.Run(() => _pumpAsync(_process.StandardOutput, stdout, _cts.Token)); + + if (!string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("PROXY_MANUAL_START"))) + { + _httpPort = 5000; + } + else + { + _httpPort = _waitForHttpPort(TimeSpan.FromSeconds(15)); + } + + if (_httpPort is null) + { + throw new InvalidOperationException($"Failed to detect test-proxy HTTP port. Output: {stdout}\nErrors: {stderr}"); + } + + Client = new TestProxyClient(new Uri(BaseUri), new TestProxyClientOptions()); + AdminClient = Client.GetTestProxyAdminClient(); + } + + private static async Task _pumpAsync(StreamReader reader, StringBuilder sink, CancellationToken ct) + { + try + { + while (!ct.IsCancellationRequested && !reader.EndOfStream) + { + var line = await reader.ReadLineAsync(ct).ConfigureAwait(false); + if (line == null) + break; + lock (sink) + { + sink.AppendLine(line); + } + } + } + catch { /* swallow */ } + } + + private int? _waitForHttpPort(TimeSpan timeout) + { + var start = DateTime.UtcNow; + while ((DateTime.UtcNow - start) < timeout) + { + string text; + lock (stdout) + { + text = stdout.ToString(); + } + foreach (var line in text.Split('\n')) + { + if (_tryParsePort(line.Trim(), out var p)) + { + return p; + } + } + if (_process?.HasExited == true) + break; + Thread.Sleep(50); + } + return null; + } + + private static bool _tryParsePort(string line, out int port) + { + port = 0; + const string prefix = "Now listening on: http://127.0.0.1:"; + if (!line.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + return false; + var remainder = line[prefix.Length..].TrimEnd('/', '\r'); + return int.TryParse(remainder, out port); + } + + /// + /// Snapshots the current stderr output from the testproxy. This is a destructive read; the internal buffer is cleared after the call. + /// + /// This means that if multiple tests fail in sequence, each test will only see the stderr output generated since the last call to SnapshotStdErr(), which means + /// we won't be seeing errors from previous test failures. This is intentional to ensure that each test only gets the relevant stderr output. + /// + /// + public string? SnapshotStdErr() + { + lock (stderr) + { + var toOutput = stderr.Length == 0 ? null : stderr.ToString(); + + stderr = new(); + + return toOutput; + } + } + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + + _cts?.Cancel(); + _cts?.Dispose(); + + if (_process != null && !_process.HasExited) + { + _process.Kill(); + } + _process?.Dispose(); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/Argument.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/Argument.cs new file mode 100644 index 0000000000..96cdca5e93 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/Argument.cs @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + internal static partial class Argument + { + /// The value. + /// The name. + public static void AssertNotNull(T value, string name) + { + if (value is null) + { + throw new ArgumentNullException(name); + } + } + + /// The value. + /// The name. + public static void AssertNotNull(T? value, string name) + where T : struct + { + if (!value.HasValue) + { + throw new ArgumentNullException(name); + } + } + + /// The value. + /// The name. + public static void AssertNotNullOrEmpty(IEnumerable value, string name) + { + if (value is null) + { + throw new ArgumentNullException(name); + } + if (value is ICollection collectionOfT && collectionOfT.Count == 0) + { + throw new ArgumentException("Value cannot be an empty collection.", name); + } + if (value is ICollection collection && collection.Count == 0) + { + throw new ArgumentException("Value cannot be an empty collection.", name); + } + using IEnumerator e = value.GetEnumerator(); + if (!e.MoveNext()) + { + throw new ArgumentException("Value cannot be an empty collection.", name); + } + } + + /// The value. + /// The name. + public static void AssertNotNullOrEmpty(string value, string name) + { + if (value is null) + { + throw new ArgumentNullException(name); + } + if (value.Length == 0) + { + throw new ArgumentException("Value cannot be an empty string.", name); + } + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/BinaryContentHelper.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/BinaryContentHelper.cs new file mode 100644 index 0000000000..5b57457029 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/BinaryContentHelper.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel; +using System.Collections.Generic; +using System.Text.Json; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + internal static partial class BinaryContentHelper + { + /// + public static BinaryContent FromEnumerable(IEnumerable enumerable) + where T : notnull + { + Utf8JsonBinaryContent content = new Utf8JsonBinaryContent(); + content.JsonWriter.WriteStartArray(); + foreach (var item in enumerable) + { + content.JsonWriter.WriteObjectValue(item, ModelSerializationExtensions.WireOptions); + } + content.JsonWriter.WriteEndArray(); + + return content; + } + + /// + public static BinaryContent FromEnumerable(IEnumerable enumerable) + { + Utf8JsonBinaryContent content = new Utf8JsonBinaryContent(); + content.JsonWriter.WriteStartArray(); + foreach (var item in enumerable) + { + if (item == null) + { + content.JsonWriter.WriteNullValue(); + } + else + { +#if NET6_0_OR_GREATER + content.JsonWriter.WriteRawValue(item); +#else + using (JsonDocument document = JsonDocument.Parse(item)) + { + JsonSerializer.Serialize(content.JsonWriter, document.RootElement); + } +#endif + } + } + content.JsonWriter.WriteEndArray(); + + return content; + } + + /// + public static BinaryContent FromEnumerable(ReadOnlySpan span) + where T : notnull + { + Utf8JsonBinaryContent content = new Utf8JsonBinaryContent(); + content.JsonWriter.WriteStartArray(); + int i = 0; + for (; i < span.Length; i++) + { + content.JsonWriter.WriteObjectValue(span[i], ModelSerializationExtensions.WireOptions); + } + content.JsonWriter.WriteEndArray(); + + return content; + } + + /// + public static BinaryContent FromDictionary(IDictionary dictionary) + where TValue : notnull + { + Utf8JsonBinaryContent content = new Utf8JsonBinaryContent(); + content.JsonWriter.WriteStartObject(); + foreach (var item in dictionary) + { + content.JsonWriter.WritePropertyName(item.Key); + content.JsonWriter.WriteObjectValue(item.Value, ModelSerializationExtensions.WireOptions); + } + content.JsonWriter.WriteEndObject(); + + return content; + } + + /// + public static BinaryContent FromDictionary(IDictionary dictionary) + { + Utf8JsonBinaryContent content = new Utf8JsonBinaryContent(); + content.JsonWriter.WriteStartObject(); + foreach (var item in dictionary) + { + content.JsonWriter.WritePropertyName(item.Key); + if (item.Value == null) + { + content.JsonWriter.WriteNullValue(); + } + else + { +#if NET6_0_OR_GREATER + content.JsonWriter.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(content.JsonWriter, document.RootElement); + } +#endif + } + } + content.JsonWriter.WriteEndObject(); + + return content; + } + + /// + public static BinaryContent FromObject(object value) + { + Utf8JsonBinaryContent content = new Utf8JsonBinaryContent(); + content.JsonWriter.WriteObjectValue(value, ModelSerializationExtensions.WireOptions); + return content; + } + + /// + public static BinaryContent FromObject(BinaryData value) + { + Utf8JsonBinaryContent content = new Utf8JsonBinaryContent(); +#if NET6_0_OR_GREATER + content.JsonWriter.WriteRawValue(value); +#else + using (JsonDocument document = JsonDocument.Parse(value)) + { + JsonSerializer.Serialize(content.JsonWriter, document.RootElement); + } +#endif + return content; + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ChangeTrackingDictionary.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ChangeTrackingDictionary.cs new file mode 100644 index 0000000000..e67a377f91 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ChangeTrackingDictionary.cs @@ -0,0 +1,189 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + internal partial class ChangeTrackingDictionary : IDictionary, IReadOnlyDictionary + where TKey : notnull + { + private IDictionary _innerDictionary; + + public ChangeTrackingDictionary() + { + } + + /// The inner dictionary. + public ChangeTrackingDictionary(IDictionary dictionary) + { + if (dictionary == null) + { + return; + } + _innerDictionary = new Dictionary(dictionary); + } + + /// The inner dictionary. + public ChangeTrackingDictionary(IReadOnlyDictionary dictionary) + { + if (dictionary == null) + { + return; + } + _innerDictionary = new Dictionary(); + foreach (var pair in dictionary) + { + _innerDictionary.Add(pair); + } + } + + /// Gets the IsUndefined. + public bool IsUndefined => _innerDictionary == null; + + /// Gets the Count. + public int Count => IsUndefined ? 0 : EnsureDictionary().Count; + + /// Gets the IsReadOnly. + public bool IsReadOnly => IsUndefined ? false : EnsureDictionary().IsReadOnly; + + /// Gets the Keys. + public ICollection Keys => IsUndefined ? Array.Empty() : EnsureDictionary().Keys; + + /// Gets the Values. + public ICollection Values => IsUndefined ? Array.Empty() : EnsureDictionary().Values; + + /// Gets or sets the value associated with the specified key. + public TValue this[TKey key] + { + get + { + if (IsUndefined) + { + throw new KeyNotFoundException(nameof(key)); + } + return EnsureDictionary()[key]; + } + set + { + EnsureDictionary()[key] = value; + } + } + + /// Gets the Keys. + IEnumerable IReadOnlyDictionary.Keys => Keys; + + /// Gets the Values. + IEnumerable IReadOnlyDictionary.Values => Values; + + public IEnumerator> GetEnumerator() + { + if (IsUndefined) + { + IEnumerator> enumerateEmpty() + { + yield break; + } + return enumerateEmpty(); + } + return EnsureDictionary().GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// The item to add. + public void Add(KeyValuePair item) + { + EnsureDictionary().Add(item); + } + + public void Clear() + { + EnsureDictionary().Clear(); + } + + /// The item to search for. + public bool Contains(KeyValuePair item) + { + if (IsUndefined) + { + return false; + } + return EnsureDictionary().Contains(item); + } + + /// The array to copy. + /// The index. + public void CopyTo(KeyValuePair[] array, int index) + { + if (IsUndefined) + { + return; + } + EnsureDictionary().CopyTo(array, index); + } + + /// The item to remove. + public bool Remove(KeyValuePair item) + { + if (IsUndefined) + { + return false; + } + return EnsureDictionary().Remove(item); + } + + /// The key. + /// The value to add. + public void Add(TKey key, TValue value) + { + EnsureDictionary().Add(key, value); + } + + /// The key to search for. + public bool ContainsKey(TKey key) + { + if (IsUndefined) + { + return false; + } + return EnsureDictionary().ContainsKey(key); + } + + /// The key. + public bool Remove(TKey key) + { + if (IsUndefined) + { + return false; + } + return EnsureDictionary().Remove(key); + } + + /// The key to search for. + /// The value. + public bool TryGetValue(TKey key, out TValue value) + { + if (IsUndefined) + { + value = default; + return false; + } + return EnsureDictionary().TryGetValue(key, out value); + } + + public IDictionary EnsureDictionary() + { + return _innerDictionary ??= new Dictionary(); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ChangeTrackingList.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ChangeTrackingList.cs new file mode 100644 index 0000000000..767fb58764 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ChangeTrackingList.cs @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + internal partial class ChangeTrackingList : IList, IReadOnlyList + { + private IList _innerList; + + public ChangeTrackingList() + { + } + + /// The inner list. + public ChangeTrackingList(IList innerList) + { + if (innerList != null) + { + _innerList = innerList; + } + } + + /// The inner list. + public ChangeTrackingList(IReadOnlyList innerList) + { + if (innerList != null) + { + _innerList = innerList.ToList(); + } + } + + /// Gets the IsUndefined. + public bool IsUndefined => _innerList == null; + + /// Gets the Count. + public int Count => IsUndefined ? 0 : EnsureList().Count; + + /// Gets the IsReadOnly. + public bool IsReadOnly => IsUndefined ? false : EnsureList().IsReadOnly; + + /// Gets or sets the value associated with the specified key. + public T this[int index] + { + get + { + if (IsUndefined) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + return EnsureList()[index]; + } + set + { + if (IsUndefined) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + EnsureList()[index] = value; + } + } + + public void Reset() + { + _innerList = null; + } + + public IEnumerator GetEnumerator() + { + if (IsUndefined) + { + IEnumerator enumerateEmpty() + { + yield break; + } + return enumerateEmpty(); + } + return EnsureList().GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + + /// The item to add. + public void Add(T item) + { + EnsureList().Add(item); + } + + public void Clear() + { + EnsureList().Clear(); + } + + /// The item. + public bool Contains(T item) + { + if (IsUndefined) + { + return false; + } + return EnsureList().Contains(item); + } + + /// The array to copy to. + /// The array index. + public void CopyTo(T[] array, int arrayIndex) + { + if (IsUndefined) + { + return; + } + EnsureList().CopyTo(array, arrayIndex); + } + + /// The item. + public bool Remove(T item) + { + if (IsUndefined) + { + return false; + } + return EnsureList().Remove(item); + } + + /// The item. + public int IndexOf(T item) + { + if (IsUndefined) + { + return -1; + } + return EnsureList().IndexOf(item); + } + + /// The inner list. + /// The item. + public void Insert(int index, T item) + { + EnsureList().Insert(index, item); + } + + /// The inner list. + public void RemoveAt(int index) + { + if (IsUndefined) + { + throw new ArgumentOutOfRangeException(nameof(index)); + } + EnsureList().RemoveAt(index); + } + + public IList EnsureList() + { + return _innerList ??= new List(); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ClientPipelineExtensions.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ClientPipelineExtensions.cs new file mode 100644 index 0000000000..d26a34c549 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ClientPipelineExtensions.cs @@ -0,0 +1,70 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Threading.Tasks; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + internal static partial class ClientPipelineExtensions + { + public static async ValueTask ProcessMessageAsync(this ClientPipeline pipeline, PipelineMessage message, RequestOptions options) + { + await pipeline.SendAsync(message).ConfigureAwait(false); + + if (message.Response.IsError && (options?.ErrorOptions & ClientErrorBehaviors.NoThrow) != ClientErrorBehaviors.NoThrow) + { + throw await ClientResultException.CreateAsync(message.Response).ConfigureAwait(false); + } + + PipelineResponse response = message.BufferResponse ? message.Response : message.ExtractResponse(); + return response; + } + + public static PipelineResponse ProcessMessage(this ClientPipeline pipeline, PipelineMessage message, RequestOptions options) + { + pipeline.Send(message); + + if (message.Response.IsError && (options?.ErrorOptions & ClientErrorBehaviors.NoThrow) != ClientErrorBehaviors.NoThrow) + { + throw new ClientResultException(message.Response); + } + + PipelineResponse response = message.BufferResponse ? message.Response : message.ExtractResponse(); + return response; + } + + public static async ValueTask> ProcessHeadAsBoolMessageAsync(this ClientPipeline pipeline, PipelineMessage message, RequestOptions options) + { + PipelineResponse response = await pipeline.ProcessMessageAsync(message, options).ConfigureAwait(false); + switch (response.Status) + { + case >= 200 and < 300: + return ClientResult.FromValue(true, response); + case >= 400 and < 500: + return ClientResult.FromValue(false, response); + default: + return new ErrorResult(response, new ClientResultException(response)); + } + } + + public static ClientResult ProcessHeadAsBoolMessage(this ClientPipeline pipeline, PipelineMessage message, RequestOptions options) + { + PipelineResponse response = pipeline.ProcessMessage(message, options); + switch (response.Status) + { + case >= 200 and < 300: + return ClientResult.FromValue(true, response); + case >= 400 and < 500: + return ClientResult.FromValue(false, response); + default: + return new ErrorResult(response, new ClientResultException(response)); + } + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ClientUriBuilder.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ClientUriBuilder.cs new file mode 100644 index 0000000000..dbc10ea7cd --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ClientUriBuilder.cs @@ -0,0 +1,134 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + internal partial class ClientUriBuilder + { + private UriBuilder _uriBuilder; + private StringBuilder _pathBuilder; + private StringBuilder _queryBuilder; + + public ClientUriBuilder() + { + } + + private UriBuilder UriBuilder => _uriBuilder ??= new UriBuilder(); + + private StringBuilder PathBuilder => _pathBuilder ??= new StringBuilder(UriBuilder.Path); + + private StringBuilder QueryBuilder => _queryBuilder ??= new StringBuilder(UriBuilder.Query); + + public void Reset(Uri uri) + { + _uriBuilder = new UriBuilder(uri); + _pathBuilder = new StringBuilder(UriBuilder.Path); + _queryBuilder = new StringBuilder(UriBuilder.Query); + } + + public void AppendPath(string value, bool escape) + { + if (escape) + { + value = Uri.EscapeDataString(value); + } + if (PathBuilder.Length > 0 && PathBuilder[PathBuilder.Length - 1] == '/' && value[0] == '/') + { + PathBuilder.Remove(PathBuilder.Length - 1, 1); + } + PathBuilder.Append(value); + UriBuilder.Path = PathBuilder.ToString(); + } + + public void AppendPath(bool value, bool escape = false) => AppendPath(TypeFormatters.ConvertToString(value), escape); + + public void AppendPath(float value, bool escape = true) => AppendPath(TypeFormatters.ConvertToString(value), escape); + + public void AppendPath(double value, bool escape = true) => AppendPath(TypeFormatters.ConvertToString(value), escape); + + public void AppendPath(int value, bool escape = true) => AppendPath(TypeFormatters.ConvertToString(value), escape); + + public void AppendPath(byte[] value, string format, bool escape = true) => AppendPath(TypeFormatters.ConvertToString(value, format), escape); + + public void AppendPath(DateTimeOffset value, string format, bool escape = true) => AppendPath(TypeFormatters.ConvertToString(value, format), escape); + + public void AppendPath(TimeSpan value, string format, bool escape = true) => AppendPath(TypeFormatters.ConvertToString(value, format), escape); + + public void AppendPath(Guid value, bool escape = true) => AppendPath(TypeFormatters.ConvertToString(value), escape); + + public void AppendPath(long value, bool escape = true) => AppendPath(TypeFormatters.ConvertToString(value), escape); + + public void AppendPathDelimited(IEnumerable value, string delimiter, string format = null, bool escape = true) + { + delimiter ??= ","; + IEnumerable stringValues = value.Select(v => TypeFormatters.ConvertToString(v, format)); + AppendPath(string.Join(delimiter, stringValues), escape); + } + + public void AppendQuery(string name, string value, bool escape) + { + if (QueryBuilder.Length > 0) + { + QueryBuilder.Append('&'); + } + if (escape) + { + value = Uri.EscapeDataString(value); + } + QueryBuilder.Append(name); + QueryBuilder.Append('='); + QueryBuilder.Append(value); + } + + public void AppendQuery(string name, bool value, bool escape = false) => AppendQuery(name, TypeFormatters.ConvertToString(value), escape); + + public void AppendQuery(string name, float value, bool escape = true) => AppendQuery(name, TypeFormatters.ConvertToString(value), escape); + + public void AppendQuery(string name, DateTimeOffset value, string format, bool escape = true) => AppendQuery(name, TypeFormatters.ConvertToString(value, format), escape); + + public void AppendQuery(string name, TimeSpan value, string format, bool escape = true) => AppendQuery(name, TypeFormatters.ConvertToString(value, format), escape); + + public void AppendQuery(string name, double value, bool escape = true) => AppendQuery(name, TypeFormatters.ConvertToString(value), escape); + + public void AppendQuery(string name, decimal value, bool escape = true) => AppendQuery(name, TypeFormatters.ConvertToString(value), escape); + + public void AppendQuery(string name, int value, bool escape = true) => AppendQuery(name, TypeFormatters.ConvertToString(value), escape); + + public void AppendQuery(string name, long value, bool escape = true) => AppendQuery(name, TypeFormatters.ConvertToString(value), escape); + + public void AppendQuery(string name, TimeSpan value, bool escape = true) => AppendQuery(name, TypeFormatters.ConvertToString(value), escape); + + public void AppendQuery(string name, byte[] value, string format, bool escape = true) => AppendQuery(name, TypeFormatters.ConvertToString(value, format), escape); + + public void AppendQuery(string name, Guid value, bool escape = true) => AppendQuery(name, TypeFormatters.ConvertToString(value), escape); + + public void AppendQueryDelimited(string name, IEnumerable value, string delimiter, string format = null, bool escape = true) + { + delimiter ??= ","; + IEnumerable stringValues = value.Select(v => TypeFormatters.ConvertToString(v, format)); + AppendQuery(name, string.Join(delimiter, stringValues), escape); + } + + public Uri ToUri() + { + if (_pathBuilder != null) + { + UriBuilder.Path = _pathBuilder.ToString(); + } + if (_queryBuilder != null) + { + UriBuilder.Query = _queryBuilder.ToString(); + } + return UriBuilder.Uri; + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenMemberAttribute.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenMemberAttribute.cs new file mode 100644 index 0000000000..c5b2d69896 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenMemberAttribute.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + [AttributeUsage((AttributeTargets.Property | AttributeTargets.Field))] + internal partial class CodeGenMemberAttribute : CodeGenTypeAttribute + { + /// The original name of the member. + public CodeGenMemberAttribute(string originalName) : base(originalName) + { + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenSerializationAttribute.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenSerializationAttribute.cs new file mode 100644 index 0000000000..ea3ab406b4 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenSerializationAttribute.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + [AttributeUsage((AttributeTargets.Class | AttributeTargets.Struct), AllowMultiple = true, Inherited = true)] + internal partial class CodeGenSerializationAttribute : Attribute + { + /// The property name which these hooks apply to. + public CodeGenSerializationAttribute(string propertyName) + { + PropertyName = propertyName; + } + + /// The property name which these hooks apply to. + /// The serialization name of the property. + public CodeGenSerializationAttribute(string propertyName, string propertySerializationName) + { + PropertyName = propertyName; + PropertySerializationName = propertySerializationName; + } + + /// Gets or sets the property name which these hooks should apply to. + public string PropertyName { get; } + + /// Gets or sets the serialization name of the property. + public string PropertySerializationName { get; set; } + + /// + /// Gets or sets the method name to use when serializing the property value (property name excluded). + /// The signature of the serialization hook method must be or compatible with when invoking: private void SerializeHook(Utf8JsonWriter writer); + /// + public string SerializationValueHook { get; set; } + + /// + /// Gets or sets the method name to use when deserializing the property value from the JSON. + /// private static void DeserializationHook(JsonProperty property, ref TypeOfTheProperty propertyValue); // if the property is required + /// private static void DeserializationHook(JsonProperty property, ref Optional<TypeOfTheProperty> propertyValue); // if the property is optional + /// + public string DeserializationValueHook { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenSuppressAttribute.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenSuppressAttribute.cs new file mode 100644 index 0000000000..4011f94f0d --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenSuppressAttribute.cs @@ -0,0 +1,29 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + [AttributeUsage((AttributeTargets.Class | AttributeTargets.Enum | AttributeTargets.Struct), AllowMultiple = true)] + internal partial class CodeGenSuppressAttribute : Attribute + { + /// The member to suppress. + /// The types of the parameters of the member. + public CodeGenSuppressAttribute(string member, params Type[] parameters) + { + Member = member; + Parameters = parameters; + } + + /// Gets the Member. + public string Member { get; } + + /// Gets the Parameters. + public Type[] Parameters { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenTypeAttribute.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenTypeAttribute.cs new file mode 100644 index 0000000000..b0a76c87e1 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/CodeGenTypeAttribute.cs @@ -0,0 +1,24 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + [AttributeUsage((AttributeTargets.Class | AttributeTargets.Enum | AttributeTargets.Struct))] + internal partial class CodeGenTypeAttribute : Attribute + { + /// The original name of the type. + public CodeGenTypeAttribute(string originalName) + { + OriginalName = originalName; + } + + /// Gets the OriginalName. + public string OriginalName { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ErrorResult.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ErrorResult.cs new file mode 100644 index 0000000000..3fc9879859 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ErrorResult.cs @@ -0,0 +1,27 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System.ClientModel; +using System.ClientModel.Primitives; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + internal partial class ErrorResult : ClientResult + { + private readonly PipelineResponse _response; + private readonly ClientResultException _exception; + + public ErrorResult(PipelineResponse response, ClientResultException exception) : base(default, response) + { + _response = response; + _exception = exception; + } + + /// Gets the Value. + public override T Value => throw _exception; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ModelSerializationExtensions.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ModelSerializationExtensions.cs new file mode 100644 index 0000000000..4ddf09d68e --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/ModelSerializationExtensions.cs @@ -0,0 +1,258 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Diagnostics; +using System.Globalization; +using System.Text.Json; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + internal static partial class ModelSerializationExtensions + { + internal static readonly ModelReaderWriterOptions WireOptions = new ModelReaderWriterOptions("W"); + internal static readonly JsonDocumentOptions JsonDocumentOptions = new JsonDocumentOptions + { + MaxDepth = 256 + }; + + public static object GetObject(this JsonElement element) + { + switch (element.ValueKind) + { + case JsonValueKind.String: + return element.GetString(); + case JsonValueKind.Number: + if (element.TryGetInt32(out int intValue)) + { + return intValue; + } + if (element.TryGetInt64(out long longValue)) + { + return longValue; + } + return element.GetDouble(); + case JsonValueKind.True: + return true; + case JsonValueKind.False: + return false; + case JsonValueKind.Undefined: + case JsonValueKind.Null: + return null; + case JsonValueKind.Object: + Dictionary dictionary = new Dictionary(); + foreach (var jsonProperty in element.EnumerateObject()) + { + dictionary.Add(jsonProperty.Name, jsonProperty.Value.GetObject()); + } + return dictionary; + case JsonValueKind.Array: + List list = new List(); + foreach (var item in element.EnumerateArray()) + { + list.Add(item.GetObject()); + } + return list.ToArray(); + default: + throw new NotSupportedException($"Not supported value kind {element.ValueKind}"); + } + } + + public static byte[] GetBytesFromBase64(this JsonElement element, string format) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + + return format switch + { + "U" => TypeFormatters.FromBase64UrlString(element.GetRequiredString()), + "D" => element.GetBytesFromBase64(), + _ => throw new ArgumentException($"Format is not supported: '{format}'", nameof(format)) + }; + } + + public static DateTimeOffset GetDateTimeOffset(this JsonElement element, string format) => format switch + { + "U" when element.ValueKind == JsonValueKind.Number => DateTimeOffset.FromUnixTimeSeconds(element.GetInt64()), + _ => TypeFormatters.ParseDateTimeOffset(element.GetString(), format) + }; + + public static TimeSpan GetTimeSpan(this JsonElement element, string format) => TypeFormatters.ParseTimeSpan(element.GetString(), format); + + public static char GetChar(this JsonElement element) + { + if (element.ValueKind == JsonValueKind.String) + { + string text = element.GetString(); + if (text == null || text.Length != 1) + { + throw new NotSupportedException($"Cannot convert \"{text}\" to a char"); + } + return text[0]; + } + else + { + throw new NotSupportedException($"Cannot convert {element.ValueKind} to a char"); + } + } + + [Conditional("DEBUG")] + public static void ThrowNonNullablePropertyIsNull(this JsonProperty @property) + { + throw new JsonException($"A property '{@property.Name}' defined as non-nullable but received as null from the service. This exception only happens in DEBUG builds of the library and would be ignored in the release build"); + } + + public static string GetRequiredString(this JsonElement element) + { + string value = element.GetString(); + if (value == null) + { + throw new InvalidOperationException($"The requested operation requires an element of type 'String', but the target element has type '{element.ValueKind}'."); + } + return value; + } + + public static void WriteStringValue(this Utf8JsonWriter writer, DateTimeOffset value, string format) + { + writer.WriteStringValue(TypeFormatters.ToString(value, format)); + } + + public static void WriteStringValue(this Utf8JsonWriter writer, DateTime value, string format) + { + writer.WriteStringValue(TypeFormatters.ToString(value, format)); + } + + public static void WriteStringValue(this Utf8JsonWriter writer, TimeSpan value, string format) + { + writer.WriteStringValue(TypeFormatters.ToString(value, format)); + } + + public static void WriteStringValue(this Utf8JsonWriter writer, char value) + { + writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture)); + } + + public static void WriteBase64StringValue(this Utf8JsonWriter writer, byte[] value, string format) + { + if (value == null) + { + writer.WriteNullValue(); + return; + } + switch (format) + { + case "U": + writer.WriteStringValue(TypeFormatters.ToBase64UrlString(value)); + break; + case "D": + writer.WriteBase64StringValue(value); + break; + default: + throw new ArgumentException($"Format is not supported: '{format}'", nameof(format)); + } + } + + public static void WriteNumberValue(this Utf8JsonWriter writer, DateTimeOffset value, string format) + { + if (format != "U") + { + throw new ArgumentOutOfRangeException(nameof(format), "Only 'U' format is supported when writing a DateTimeOffset as a Number."); + } + writer.WriteNumberValue(value.ToUnixTimeSeconds()); + } + + public static void WriteObjectValue(this Utf8JsonWriter writer, T value, ModelReaderWriterOptions options = null) + { + switch (value) + { + case null: + writer.WriteNullValue(); + break; + case IJsonModel jsonModel: + jsonModel.Write(writer, options ?? WireOptions); + break; + case byte[] bytes: + writer.WriteBase64StringValue(bytes); + break; + case BinaryData bytes0: + writer.WriteBase64StringValue(bytes0); + break; + case JsonElement json: + json.WriteTo(writer); + break; + case int i: + writer.WriteNumberValue(i); + break; + case decimal d: + writer.WriteNumberValue(d); + break; + case double d0: + if (double.IsNaN(d0)) + { + writer.WriteStringValue("NaN"); + } + else + { + writer.WriteNumberValue(d0); + } + break; + case float f: + writer.WriteNumberValue(f); + break; + case long l: + writer.WriteNumberValue(l); + break; + case string s: + writer.WriteStringValue(s); + break; + case bool b: + writer.WriteBooleanValue(b); + break; + case Guid g: + writer.WriteStringValue(g); + break; + case DateTimeOffset dateTimeOffset: + writer.WriteStringValue(dateTimeOffset, "O"); + break; + case DateTime dateTime: + writer.WriteStringValue(dateTime, "O"); + break; + case IEnumerable> enumerable: + writer.WriteStartObject(); + foreach (var pair in enumerable) + { + writer.WritePropertyName(pair.Key); + writer.WriteObjectValue(pair.Value, options); + } + writer.WriteEndObject(); + break; + case IEnumerable objectEnumerable: + writer.WriteStartArray(); + foreach (var item in objectEnumerable) + { + writer.WriteObjectValue(item, options); + } + writer.WriteEndArray(); + break; + case TimeSpan timeSpan: + writer.WriteStringValue(timeSpan, "P"); + break; + default: + throw new NotSupportedException($"Not supported type {value.GetType()}"); + } + } + + public static void WriteObjectValue(this Utf8JsonWriter writer, object value, ModelReaderWriterOptions options = null) + { + writer.WriteObjectValue(value, options); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/Optional.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/Optional.cs new file mode 100644 index 0000000000..1146285bad --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/Optional.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System.Collections.Generic; +using System.Text.Json; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + internal static partial class Optional + { + public static bool IsCollectionDefined(IEnumerable collection) + { + return !(collection is ChangeTrackingList changeTrackingList && changeTrackingList.IsUndefined); + } + + public static bool IsCollectionDefined(IDictionary collection) + { + return !(collection is ChangeTrackingDictionary changeTrackingDictionary && changeTrackingDictionary.IsUndefined); + } + + public static bool IsCollectionDefined(IReadOnlyDictionary collection) + { + return !(collection is ChangeTrackingDictionary changeTrackingDictionary && changeTrackingDictionary.IsUndefined); + } + + public static bool IsDefined(T? value) + where T : struct + { + return value.HasValue; + } + + public static bool IsDefined(object value) + { + return value != null; + } + + public static bool IsDefined(string value) + { + return value != null; + } + + public static bool IsDefined(JsonElement value) + { + return value.ValueKind != JsonValueKind.Undefined; + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/TypeFormatters.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/TypeFormatters.cs new file mode 100644 index 0000000000..e99c9f5e3f --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/TypeFormatters.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using System.Globalization; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + internal static partial class TypeFormatters + { + private const string RoundtripZFormat = "yyyy-MM-ddTHH:mm:ss.fffffffZ"; + public const string DefaultNumberFormat = "G"; + + public static string ToString(bool value) => value ? "true" : "false"; + + public static string ToString(DateTime value, string format) => value.Kind switch + { + DateTimeKind.Utc => ToString((DateTimeOffset)value, format), + _ => throw new NotSupportedException($"DateTime {value} has a Kind of {value.Kind}. Generated clients require it to be UTC. You can call DateTime.SpecifyKind to change Kind property value to DateTimeKind.Utc.") + }; + + public static string ToString(DateTimeOffset value, string format) => format switch + { + "D" => value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture), + "U" => value.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture), + "O" => value.ToUniversalTime().ToString(RoundtripZFormat, CultureInfo.InvariantCulture), + "o" => value.ToUniversalTime().ToString(RoundtripZFormat, CultureInfo.InvariantCulture), + "R" => value.ToString("r", CultureInfo.InvariantCulture), + _ => value.ToString(format, CultureInfo.InvariantCulture) + }; + + public static string ToString(TimeSpan value, string format) => format switch + { + "P" => System.Xml.XmlConvert.ToString(value), + _ => value.ToString(format, CultureInfo.InvariantCulture) + }; + + public static string ToString(byte[] value, string format) => format switch + { + "U" => ToBase64UrlString(value), + "D" => Convert.ToBase64String(value), + _ => throw new ArgumentException($"Format is not supported: '{format}'", nameof(format)) + }; + + public static string ToBase64UrlString(byte[] value) + { + int numWholeOrPartialInputBlocks = checked (value.Length + 2) / 3; + int size = checked (numWholeOrPartialInputBlocks * 4); + char[] output = new char[size]; + + int numBase64Chars = Convert.ToBase64CharArray(value, 0, value.Length, output, 0); + + int i = 0; + for (; i < numBase64Chars; i++) + { + char ch = output[i]; + if (ch == '+') + { + output[i] = '-'; + } + else + { + if (ch == '/') + { + output[i] = '_'; + } + else + { + if (ch == '=') + { + break; + } + } + } + } + + return new string(output, 0, i); + } + + public static byte[] FromBase64UrlString(string value) + { + int paddingCharsToAdd = (value.Length % 4) switch + { + 0 => 0, + 2 => 2, + 3 => 1, + _ => throw new InvalidOperationException("Malformed input") + }; + char[] output = new char[(value.Length + paddingCharsToAdd)]; + int i = 0; + for (; i < value.Length; i++) + { + char ch = value[i]; + if (ch == '-') + { + output[i] = '+'; + } + else + { + if (ch == '_') + { + output[i] = '/'; + } + else + { + output[i] = ch; + } + } + } + + for (; i < output.Length; i++) + { + output[i] = '='; + } + + return Convert.FromBase64CharArray(output, 0, output.Length); + } + + public static DateTimeOffset ParseDateTimeOffset(string value, string format) => format switch + { + "U" => DateTimeOffset.FromUnixTimeSeconds(long.Parse(value, CultureInfo.InvariantCulture)), + _ => DateTimeOffset.Parse(value, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal) + }; + + public static TimeSpan ParseTimeSpan(string value, string format) => format switch + { + "P" => System.Xml.XmlConvert.ToTimeSpan(value), + _ => TimeSpan.ParseExact(value, format, CultureInfo.InvariantCulture) + }; + + public static string ConvertToString(object value, string format = null) => value switch + { + null => "null", + string s => s, + bool b => ToString(b), + int or float or double or long or decimal => ((IFormattable)value).ToString(DefaultNumberFormat, CultureInfo.InvariantCulture), + byte[] b0 when format != null => ToString(b0, format), + IEnumerable s0 => string.Join(",", s0), + DateTimeOffset dateTime when format != null => ToString(dateTime, format), + TimeSpan timeSpan when format != null => ToString(timeSpan, format), + TimeSpan timeSpan0 => System.Xml.XmlConvert.ToString(timeSpan0), + Guid guid => guid.ToString(), + BinaryData binaryData => ConvertToString(binaryData.ToArray(), format), + _ => value.ToString() + }; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/Utf8JsonBinaryContent.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/Utf8JsonBinaryContent.cs new file mode 100644 index 0000000000..a7cfad2882 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Internal/Utf8JsonBinaryContent.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System.ClientModel; +using System.IO; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + internal partial class Utf8JsonBinaryContent : BinaryContent + { + private readonly MemoryStream _stream; + private readonly BinaryContent _content; + + public Utf8JsonBinaryContent() + { + _stream = new MemoryStream(); + _content = Create(_stream); + JsonWriter = new Utf8JsonWriter(_stream); + } + + /// Gets the JsonWriter. + public Utf8JsonWriter JsonWriter { get; } + + /// The stream containing the data to be written. + /// The cancellation token to use. + public override async Task WriteToAsync(Stream stream, CancellationToken cancellationToken = default) + { + await JsonWriter.FlushAsync().ConfigureAwait(false); + await _content.WriteToAsync(stream, cancellationToken).ConfigureAwait(false); + } + + /// The stream containing the data to be written. + /// The cancellation token to use. + public override void WriteTo(Stream stream, CancellationToken cancellationToken = default) + { + JsonWriter.Flush(); + _content.WriteTo(stream, cancellationToken); + } + + /// + public override bool TryComputeLength(out long length) + { + length = JsonWriter.BytesCommitted + JsonWriter.BytesPending; + return true; + } + + public override void Dispose() + { + JsonWriter.Dispose(); + _content.Dispose(); + _stream.Dispose(); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/MicrosoftClientModelTestFrameworkModelFactory.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/MicrosoftClientModelTestFrameworkModelFactory.cs new file mode 100644 index 0000000000..7d385dc478 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/MicrosoftClientModelTestFrameworkModelFactory.cs @@ -0,0 +1,363 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System.Collections.Generic; +using System.Linq; +using Azure.Mcp.Tests.Generated.Models; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated +{ + /// A factory class for creating instances of the models for mocking. + public static partial class MicrosoftClientModelTestFrameworkModelFactory + { + /// The TestProxyStartInformation. + /// + /// + /// A new instance for mocking. + public static TestProxyStartInformation TestProxyStartInformation(string xRecordingFile = default, string xRecordingAssetsFile = default) + { + return new TestProxyStartInformation(xRecordingFile, xRecordingAssetsFile, additionalBinaryDataProperties: null); + } + + /// The CustomDefaultMatcher. + /// + /// + /// + /// + /// + /// A new instance for mocking. + public static CustomDefaultMatcher CustomDefaultMatcher(bool? compareBodies = default, string excludedHeaders = default, string ignoredHeaders = default, bool? ignoreQueryOrdering = default, string ignoredQueryParameters = default) + { + return new CustomDefaultMatcher( + compareBodies, + excludedHeaders, + ignoredHeaders, + ignoreQueryOrdering, + ignoredQueryParameters, + additionalBinaryDataProperties: null); + } + + /// + /// The SanitizerAddition. + /// Please note this is the abstract base class. The derived classes available for instantiation are: , , , , , , , , , , , , and . + /// + /// + /// A new instance for mocking. + public static SanitizerAddition SanitizerAddition(string name = default) + { + return new UnknownSanitizerAddition(name.ToSanitizerType(), additionalBinaryDataProperties: null); + } + + /// The BodyKeySanitizer. + /// + /// A new instance for mocking. + public static BodyKeySanitizer BodyKeySanitizer(BodyKeySanitizerBody body = default) + { + return new BodyKeySanitizer(SanitizerType.BodyKeySanitizer, additionalBinaryDataProperties: null, body); + } + + /// The BodyKeySanitizerBody. + /// + /// + /// + /// + /// + /// A new instance for mocking. + public static BodyKeySanitizerBody BodyKeySanitizerBody(string jsonPath = default, string value = default, string regex = default, string groupForReplace = default, ApplyCondition condition = default) + { + return new BodyKeySanitizerBody( + jsonPath, + value, + regex, + groupForReplace, + condition, + additionalBinaryDataProperties: null); + } + + /// The ApplyCondition. + /// + /// A new instance for mocking. + public static ApplyCondition ApplyCondition(string uriRegex = default) + { + return new ApplyCondition(uriRegex, additionalBinaryDataProperties: null); + } + + /// The BodyRegexSanitizer. + /// + /// A new instance for mocking. + public static BodyRegexSanitizer BodyRegexSanitizer(BodyRegexSanitizerBody body = default) + { + return new BodyRegexSanitizer(SanitizerType.BodyRegexSanitizer, additionalBinaryDataProperties: null, body); + } + + /// The BodyRegexSanitizerBody. + /// + /// + /// + /// + /// A new instance for mocking. + public static BodyRegexSanitizerBody BodyRegexSanitizerBody(string value = default, string regex = default, string groupForReplace = default, ApplyCondition condition = default) + { + return new BodyRegexSanitizerBody(value, regex, groupForReplace, condition, additionalBinaryDataProperties: null); + } + + /// The BodyStringSanitizer. + /// + /// A new instance for mocking. + public static BodyStringSanitizer BodyStringSanitizer(BodyStringSanitizerBody body = default) + { + return new BodyStringSanitizer(SanitizerType.BodyStringSanitizer, additionalBinaryDataProperties: null, body); + } + + /// The BodyStringSanitizerBody. + /// + /// + /// + /// A new instance for mocking. + public static BodyStringSanitizerBody BodyStringSanitizerBody(string target = default, string value = default, ApplyCondition condition = default) + { + return new BodyStringSanitizerBody(target, value, condition, additionalBinaryDataProperties: null); + } + + /// The GeneralRegexSanitizer. + /// + /// A new instance for mocking. + public static GeneralRegexSanitizer GeneralRegexSanitizer(GeneralRegexSanitizerBody body = default) + { + return new GeneralRegexSanitizer(SanitizerType.GeneralRegexSanitizer, additionalBinaryDataProperties: null, body); + } + + /// The GeneralRegexSanitizerBody. + /// + /// + /// + /// + /// A new instance for mocking. + public static GeneralRegexSanitizerBody GeneralRegexSanitizerBody(string value = default, string regex = default, string groupForReplace = default, ApplyCondition condition = default) + { + return new GeneralRegexSanitizerBody(value, regex, groupForReplace, condition, additionalBinaryDataProperties: null); + } + + /// The GeneralStringSanitizer. + /// + /// A new instance for mocking. + public static GeneralStringSanitizer GeneralStringSanitizer(GeneralStringSanitizerBody body = default) + { + return new GeneralStringSanitizer(SanitizerType.GeneralStringSanitizer, additionalBinaryDataProperties: null, body); + } + + /// The GeneralStringSanitizerBody. + /// + /// + /// + /// A new instance for mocking. + public static GeneralStringSanitizerBody GeneralStringSanitizerBody(string target = default, string value = default, ApplyCondition condition = default) + { + return new GeneralStringSanitizerBody(target, value, condition, additionalBinaryDataProperties: null); + } + + /// The HeaderRegexSanitizer. + /// + /// A new instance for mocking. + public static HeaderRegexSanitizer HeaderRegexSanitizer(HeaderRegexSanitizerBody body = default) + { + return new HeaderRegexSanitizer(SanitizerType.HeaderRegexSanitizer, additionalBinaryDataProperties: null, body); + } + + /// The HeaderRegexSanitizerBody. + /// + /// + /// + /// + /// + /// A new instance for mocking. + public static HeaderRegexSanitizerBody HeaderRegexSanitizerBody(string key = default, string value = default, string regex = default, string groupForReplace = default, ApplyCondition condition = default) + { + return new HeaderRegexSanitizerBody( + key, + value, + regex, + groupForReplace, + condition, + additionalBinaryDataProperties: null); + } + + /// The HeaderStringSanitizer. + /// + /// A new instance for mocking. + public static HeaderStringSanitizer HeaderStringSanitizer(HeaderStringSanitizerBody body = default) + { + return new HeaderStringSanitizer(SanitizerType.HeaderStringSanitizer, additionalBinaryDataProperties: null, body); + } + + /// The HeaderStringSanitizerBody. + /// + /// + /// + /// + /// A new instance for mocking. + public static HeaderStringSanitizerBody HeaderStringSanitizerBody(string key = default, string target = default, string value = default, ApplyCondition condition = default) + { + return new HeaderStringSanitizerBody(key, target, value, condition, additionalBinaryDataProperties: null); + } + + /// The OAuthResponseSanitizer. + /// A new instance for mocking. + public static OAuthResponseSanitizer OAuthResponseSanitizer() + { + return new OAuthResponseSanitizer(SanitizerType.OAuthResponseSanitizer, additionalBinaryDataProperties: null); + } + + /// The RegexEntrySanitizer. + /// + /// A new instance for mocking. + public static RegexEntrySanitizer RegexEntrySanitizer(RegexEntrySanitizerBody body = default) + { + return new RegexEntrySanitizer(SanitizerType.RegexEntrySanitizer, additionalBinaryDataProperties: null, body); + } + + /// The RegexEntrySanitizerBody. + /// + /// + /// A new instance for mocking. + public static RegexEntrySanitizerBody RegexEntrySanitizerBody(RegexEntryValues target = default, string regex = default) + { + return new RegexEntrySanitizerBody(target, regex, additionalBinaryDataProperties: null); + } + + /// The RemoveHeaderSanitizer. + /// + /// A new instance for mocking. + public static RemoveHeaderSanitizer RemoveHeaderSanitizer(RemoveHeaderSanitizerBody body = default) + { + return new RemoveHeaderSanitizer(SanitizerType.RemoveHeaderSanitizer, additionalBinaryDataProperties: null, body); + } + + /// The RemoveHeaderSanitizerBody. + /// + /// A new instance for mocking. + public static RemoveHeaderSanitizerBody RemoveHeaderSanitizerBody(string headersForRemoval = default) + { + return new RemoveHeaderSanitizerBody(headersForRemoval, additionalBinaryDataProperties: null); + } + + /// The UriRegexSanitizer. + /// + /// A new instance for mocking. + public static UriRegexSanitizer UriRegexSanitizer(UriRegexSanitizerBody body = default) + { + return new UriRegexSanitizer(SanitizerType.UriRegexSanitizer, additionalBinaryDataProperties: null, body); + } + + /// The UriRegexSanitizerBody. + /// + /// + /// + /// + /// A new instance for mocking. + public static UriRegexSanitizerBody UriRegexSanitizerBody(string value = default, string regex = default, string groupForReplace = default, ApplyCondition condition = default) + { + return new UriRegexSanitizerBody(value, regex, groupForReplace, condition, additionalBinaryDataProperties: null); + } + + /// The UriStringSanitizer. + /// + /// A new instance for mocking. + public static UriStringSanitizer UriStringSanitizer(UriStringSanitizerBody body = default) + { + return new UriStringSanitizer(SanitizerType.UriStringSanitizer, additionalBinaryDataProperties: null, body); + } + + /// The UriStringSanitizerBody. + /// + /// + /// + /// A new instance for mocking. + public static UriStringSanitizerBody UriStringSanitizerBody(string target = default, string value = default, ApplyCondition condition = default) + { + return new UriStringSanitizerBody(target, value, condition, additionalBinaryDataProperties: null); + } + + /// The UriSubscriptionIdSanitizer. + /// + /// A new instance for mocking. + public static UriSubscriptionIdSanitizer UriSubscriptionIdSanitizer(UriSubscriptionIdSanitizerBody body = default) + { + return new UriSubscriptionIdSanitizer(SanitizerType.UriSubscriptionIdSanitizer, additionalBinaryDataProperties: null, body); + } + + /// The UriSubscriptionIdSanitizerBody. + /// + /// + /// A new instance for mocking. + public static UriSubscriptionIdSanitizerBody UriSubscriptionIdSanitizerBody(string value = default, ApplyCondition condition = default) + { + return new UriSubscriptionIdSanitizerBody(value, condition, additionalBinaryDataProperties: null); + } + + /// The SanitizerList. + /// + /// A new instance for mocking. + public static SanitizerList SanitizerList(IEnumerable sanitizers = default) + { + sanitizers ??= new ChangeTrackingList(); + + return new SanitizerList(sanitizers.ToList(), additionalBinaryDataProperties: null); + } + + /// The RemovedSanitizers. + /// + /// A new instance for mocking. + public static RemovedSanitizers RemovedSanitizers(IEnumerable removed = default) + { + removed ??= new ChangeTrackingList(); + + return new RemovedSanitizers(removed.ToList(), additionalBinaryDataProperties: null); + } + + /// The RecordingOptions. + /// + /// + /// + /// + /// A new instance for mocking. + public static RecordingOptions RecordingOptions(bool? handleRedirects = default, string contextDirectory = default, StoreType? assetsStore = default, TransportCustomizations transport = default) + { + return new RecordingOptions(handleRedirects, contextDirectory, assetsStore, transport, additionalBinaryDataProperties: null); + } + + /// The TransportCustomizations. + /// + /// + /// + /// + /// + /// A new instance for mocking. + public static TransportCustomizations TransportCustomizations(bool? allowAutoRedirect = default, string tlsValidationCert = default, string tlsValidationCertHost = default, IEnumerable certificates = default, int? playbackResponseTime = default) + { + certificates ??= new ChangeTrackingList(); + + return new TransportCustomizations( + allowAutoRedirect, + tlsValidationCert, + tlsValidationCertHost, + certificates.ToList(), + playbackResponseTime, + additionalBinaryDataProperties: null); + } + + /// The TestProxyCertificate. + /// + /// + /// A new instance for mocking. + public static TestProxyCertificate TestProxyCertificate(string pemValue = default, string pemKey = default) + { + return new TestProxyCertificate(pemValue, pemKey, additionalBinaryDataProperties: null); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/ApplyCondition.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/ApplyCondition.Serialization.cs new file mode 100644 index 0000000000..7e3b3b6b50 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/ApplyCondition.Serialization.cs @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The ApplyCondition. + public partial class ApplyCondition : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal ApplyCondition() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(ApplyCondition)} does not support writing '{format}' format."); + } + writer.WritePropertyName("UriRegex"u8); + writer.WriteStringValue(UriRegex); + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + ApplyCondition IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual ApplyCondition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(ApplyCondition)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeApplyCondition(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static ApplyCondition DeserializeApplyCondition(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string uriRegex = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("UriRegex"u8)) + { + uriRegex = prop.Value.GetString(); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new ApplyCondition(uriRegex, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(ApplyCondition)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + ApplyCondition IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual ApplyCondition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeApplyCondition(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(ApplyCondition)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/ApplyCondition.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/ApplyCondition.cs new file mode 100644 index 0000000000..4e211f6cbf --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/ApplyCondition.cs @@ -0,0 +1,42 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The ApplyCondition. + public partial class ApplyCondition + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + /// is null. + public ApplyCondition(string uriRegex) + { + Argument.AssertNotNull(uriRegex, nameof(uriRegex)); + + UriRegex = uriRegex; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + internal ApplyCondition(string uriRegex, IDictionary additionalBinaryDataProperties) + { + UriRegex = uriRegex; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the UriRegex. + public string UriRegex { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizer.Serialization.cs new file mode 100644 index 0000000000..ec35451f33 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizer.Serialization.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The BodyKeySanitizer. + public partial class BodyKeySanitizer : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal BodyKeySanitizer() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(BodyKeySanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + writer.WritePropertyName("Body"u8); + writer.WriteObjectValue(Body, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + BodyKeySanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (BodyKeySanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(BodyKeySanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeBodyKeySanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static BodyKeySanitizer DeserializeBodyKeySanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + BodyKeySanitizerBody body = default; + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (prop.NameEquals("Body"u8)) + { + body = BodyKeySanitizerBody.DeserializeBodyKeySanitizerBody(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new BodyKeySanitizer(name, additionalBinaryDataProperties, body); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(BodyKeySanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + BodyKeySanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (BodyKeySanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeBodyKeySanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(BodyKeySanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizer.cs new file mode 100644 index 0000000000..1dde62897a --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizer.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The BodyKeySanitizer. + public partial class BodyKeySanitizer : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// is null. + public BodyKeySanitizer(BodyKeySanitizerBody body) : base(SanitizerType.BodyKeySanitizer) + { + Argument.AssertNotNull(body, nameof(body)); + + Body = body; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + /// + internal BodyKeySanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties, BodyKeySanitizerBody body) : base(name, additionalBinaryDataProperties) + { + Body = body; + } + + /// Gets the Body. + public BodyKeySanitizerBody Body { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizerBody.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizerBody.Serialization.cs new file mode 100644 index 0000000000..70f3d466bf --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizerBody.Serialization.cs @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The BodyKeySanitizerBody. + public partial class BodyKeySanitizerBody : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal BodyKeySanitizerBody() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(BodyKeySanitizerBody)} does not support writing '{format}' format."); + } + writer.WritePropertyName("jsonPath"u8); + writer.WriteStringValue(JsonPath); + if (Optional.IsDefined(Value)) + { + writer.WritePropertyName("value"u8); + writer.WriteStringValue(Value); + } + if (Optional.IsDefined(Regex)) + { + writer.WritePropertyName("regex"u8); + writer.WriteStringValue(Regex); + } + if (Optional.IsDefined(GroupForReplace)) + { + writer.WritePropertyName("groupForReplace"u8); + writer.WriteStringValue(GroupForReplace); + } + if (Optional.IsDefined(Condition)) + { + writer.WritePropertyName("condition"u8); + writer.WriteObjectValue(Condition, options); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + BodyKeySanitizerBody IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual BodyKeySanitizerBody JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(BodyKeySanitizerBody)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeBodyKeySanitizerBody(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static BodyKeySanitizerBody DeserializeBodyKeySanitizerBody(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string jsonPath = default; + string value = default; + string regex = default; + string groupForReplace = default; + ApplyCondition condition = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("jsonPath"u8)) + { + jsonPath = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("value"u8)) + { + value = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("regex"u8)) + { + regex = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("groupForReplace"u8)) + { + groupForReplace = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("condition"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + condition = ApplyCondition.DeserializeApplyCondition(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new BodyKeySanitizerBody( + jsonPath, + value, + regex, + groupForReplace, + condition, + additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(BodyKeySanitizerBody)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + BodyKeySanitizerBody IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual BodyKeySanitizerBody PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeBodyKeySanitizerBody(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(BodyKeySanitizerBody)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizerBody.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizerBody.cs new file mode 100644 index 0000000000..c5733ad0f5 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyKeySanitizerBody.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The BodyKeySanitizerBody. + public partial class BodyKeySanitizerBody + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + /// is null. + public BodyKeySanitizerBody(string jsonPath) + { + Argument.AssertNotNull(jsonPath, nameof(jsonPath)); + + JsonPath = jsonPath; + } + + /// Initializes a new instance of . + /// + /// + /// + /// + /// + /// Keeps track of any properties unknown to the library. + internal BodyKeySanitizerBody(string jsonPath, string value, string regex, string groupForReplace, ApplyCondition condition, IDictionary additionalBinaryDataProperties) + { + JsonPath = jsonPath; + Value = value; + Regex = regex; + GroupForReplace = groupForReplace; + Condition = condition; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the JsonPath. + public string JsonPath { get; } + + /// Gets or sets the Value. + public string Value { get; set; } + + /// Gets or sets the Regex. + public string Regex { get; set; } + + /// Gets or sets the GroupForReplace. + public string GroupForReplace { get; set; } + + /// Gets or sets the Condition. + public ApplyCondition Condition { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizer.Serialization.cs new file mode 100644 index 0000000000..6e22629eee --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizer.Serialization.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The BodyRegexSanitizer. + public partial class BodyRegexSanitizer : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal BodyRegexSanitizer() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(BodyRegexSanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + writer.WritePropertyName("Body"u8); + writer.WriteObjectValue(Body, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + BodyRegexSanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (BodyRegexSanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(BodyRegexSanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeBodyRegexSanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static BodyRegexSanitizer DeserializeBodyRegexSanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + BodyRegexSanitizerBody body = default; + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (prop.NameEquals("Body"u8)) + { + body = BodyRegexSanitizerBody.DeserializeBodyRegexSanitizerBody(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new BodyRegexSanitizer(name, additionalBinaryDataProperties, body); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(BodyRegexSanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + BodyRegexSanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (BodyRegexSanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeBodyRegexSanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(BodyRegexSanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizer.cs new file mode 100644 index 0000000000..4e4b509b20 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizer.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The BodyRegexSanitizer. + public partial class BodyRegexSanitizer : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// is null. + public BodyRegexSanitizer(BodyRegexSanitizerBody body) : base(SanitizerType.BodyRegexSanitizer) + { + Argument.AssertNotNull(body, nameof(body)); + + Body = body; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + /// + internal BodyRegexSanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties, BodyRegexSanitizerBody body) : base(name, additionalBinaryDataProperties) + { + Body = body; + } + + /// Gets the Body. + public BodyRegexSanitizerBody Body { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizerBody.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizerBody.Serialization.cs new file mode 100644 index 0000000000..eede767cc8 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizerBody.Serialization.cs @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The BodyRegexSanitizerBody. + public partial class BodyRegexSanitizerBody : IJsonModel + { + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(BodyRegexSanitizerBody)} does not support writing '{format}' format."); + } + if (Optional.IsDefined(Value)) + { + writer.WritePropertyName("value"u8); + writer.WriteStringValue(Value); + } + if (Optional.IsDefined(Regex)) + { + writer.WritePropertyName("regex"u8); + writer.WriteStringValue(Regex); + } + if (Optional.IsDefined(GroupForReplace)) + { + writer.WritePropertyName("groupForReplace"u8); + writer.WriteStringValue(GroupForReplace); + } + if (Optional.IsDefined(Condition)) + { + writer.WritePropertyName("condition"u8); + writer.WriteObjectValue(Condition, options); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + BodyRegexSanitizerBody IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual BodyRegexSanitizerBody JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(BodyRegexSanitizerBody)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeBodyRegexSanitizerBody(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static BodyRegexSanitizerBody DeserializeBodyRegexSanitizerBody(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string value = default; + string regex = default; + string groupForReplace = default; + ApplyCondition condition = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("value"u8)) + { + value = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("regex"u8)) + { + regex = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("groupForReplace"u8)) + { + groupForReplace = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("condition"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + condition = ApplyCondition.DeserializeApplyCondition(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new BodyRegexSanitizerBody(value, regex, groupForReplace, condition, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(BodyRegexSanitizerBody)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + BodyRegexSanitizerBody IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual BodyRegexSanitizerBody PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeBodyRegexSanitizerBody(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(BodyRegexSanitizerBody)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizerBody.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizerBody.cs new file mode 100644 index 0000000000..fb414d491b --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyRegexSanitizerBody.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The BodyRegexSanitizerBody. + public partial class BodyRegexSanitizerBody + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + public BodyRegexSanitizerBody() + { + } + + /// Initializes a new instance of . + /// + /// + /// + /// + /// Keeps track of any properties unknown to the library. + internal BodyRegexSanitizerBody(string value, string regex, string groupForReplace, ApplyCondition condition, IDictionary additionalBinaryDataProperties) + { + Value = value; + Regex = regex; + GroupForReplace = groupForReplace; + Condition = condition; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets or sets the Value. + public string Value { get; set; } + + /// Gets or sets the Regex. + public string Regex { get; set; } + + /// Gets or sets the GroupForReplace. + public string GroupForReplace { get; set; } + + /// Gets or sets the Condition. + public ApplyCondition Condition { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizer.Serialization.cs new file mode 100644 index 0000000000..ed163f1b2f --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizer.Serialization.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The BodyStringSanitizer. + public partial class BodyStringSanitizer : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal BodyStringSanitizer() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(BodyStringSanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + writer.WritePropertyName("Body"u8); + writer.WriteObjectValue(Body, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + BodyStringSanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (BodyStringSanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(BodyStringSanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeBodyStringSanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static BodyStringSanitizer DeserializeBodyStringSanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + BodyStringSanitizerBody body = default; + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (prop.NameEquals("Body"u8)) + { + body = BodyStringSanitizerBody.DeserializeBodyStringSanitizerBody(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new BodyStringSanitizer(name, additionalBinaryDataProperties, body); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(BodyStringSanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + BodyStringSanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (BodyStringSanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeBodyStringSanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(BodyStringSanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizer.cs new file mode 100644 index 0000000000..5bbd3f4ce9 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizer.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The BodyStringSanitizer. + public partial class BodyStringSanitizer : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// is null. + public BodyStringSanitizer(BodyStringSanitizerBody body) : base(SanitizerType.BodyStringSanitizer) + { + Argument.AssertNotNull(body, nameof(body)); + + Body = body; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + /// + internal BodyStringSanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties, BodyStringSanitizerBody body) : base(name, additionalBinaryDataProperties) + { + Body = body; + } + + /// Gets the Body. + public BodyStringSanitizerBody Body { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizerBody.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizerBody.Serialization.cs new file mode 100644 index 0000000000..62da1e2e05 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizerBody.Serialization.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The BodyStringSanitizerBody. + public partial class BodyStringSanitizerBody : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal BodyStringSanitizerBody() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(BodyStringSanitizerBody)} does not support writing '{format}' format."); + } + writer.WritePropertyName("target"u8); + writer.WriteStringValue(Target); + if (Optional.IsDefined(Value)) + { + writer.WritePropertyName("value"u8); + writer.WriteStringValue(Value); + } + if (Optional.IsDefined(Condition)) + { + writer.WritePropertyName("condition"u8); + writer.WriteObjectValue(Condition, options); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + BodyStringSanitizerBody IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual BodyStringSanitizerBody JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(BodyStringSanitizerBody)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeBodyStringSanitizerBody(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static BodyStringSanitizerBody DeserializeBodyStringSanitizerBody(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string target = default; + string value = default; + ApplyCondition condition = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("target"u8)) + { + target = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("value"u8)) + { + value = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("condition"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + condition = ApplyCondition.DeserializeApplyCondition(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new BodyStringSanitizerBody(target, value, condition, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(BodyStringSanitizerBody)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + BodyStringSanitizerBody IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual BodyStringSanitizerBody PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeBodyStringSanitizerBody(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(BodyStringSanitizerBody)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizerBody.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizerBody.cs new file mode 100644 index 0000000000..4c750b7421 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/BodyStringSanitizerBody.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The BodyStringSanitizerBody. + public partial class BodyStringSanitizerBody + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + /// is null. + public BodyStringSanitizerBody(string target) + { + Argument.AssertNotNull(target, nameof(target)); + + Target = target; + } + + /// Initializes a new instance of . + /// + /// + /// + /// Keeps track of any properties unknown to the library. + internal BodyStringSanitizerBody(string target, string value, ApplyCondition condition, IDictionary additionalBinaryDataProperties) + { + Target = target; + Value = value; + Condition = condition; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the Target. + public string Target { get; } + + /// Gets or sets the Value. + public string Value { get; set; } + + /// Gets or sets the Condition. + public ApplyCondition Condition { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/CustomDefaultMatcher.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/CustomDefaultMatcher.Serialization.cs new file mode 100644 index 0000000000..de61f5f163 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/CustomDefaultMatcher.Serialization.cs @@ -0,0 +1,211 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The CustomDefaultMatcher. + public partial class CustomDefaultMatcher : IJsonModel + { + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(CustomDefaultMatcher)} does not support writing '{format}' format."); + } + if (Optional.IsDefined(CompareBodies)) + { + writer.WritePropertyName("compareBodies"u8); + writer.WriteBooleanValue(CompareBodies.Value); + } + if (Optional.IsDefined(ExcludedHeaders)) + { + writer.WritePropertyName("excludedHeaders"u8); + writer.WriteStringValue(ExcludedHeaders); + } + if (Optional.IsDefined(IgnoredHeaders)) + { + writer.WritePropertyName("ignoredHeaders"u8); + writer.WriteStringValue(IgnoredHeaders); + } + if (Optional.IsDefined(IgnoreQueryOrdering)) + { + writer.WritePropertyName("ignoreQueryOrdering"u8); + writer.WriteBooleanValue(IgnoreQueryOrdering.Value); + } + if (Optional.IsDefined(IgnoredQueryParameters)) + { + writer.WritePropertyName("ignoredQueryParameters"u8); + writer.WriteStringValue(IgnoredQueryParameters); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + CustomDefaultMatcher IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual CustomDefaultMatcher JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(CustomDefaultMatcher)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeCustomDefaultMatcher(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static CustomDefaultMatcher DeserializeCustomDefaultMatcher(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + bool? compareBodies = default; + string excludedHeaders = default; + string ignoredHeaders = default; + bool? ignoreQueryOrdering = default; + string ignoredQueryParameters = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("compareBodies"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + compareBodies = prop.Value.GetBoolean(); + continue; + } + if (prop.NameEquals("excludedHeaders"u8)) + { + excludedHeaders = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("ignoredHeaders"u8)) + { + ignoredHeaders = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("ignoreQueryOrdering"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + ignoreQueryOrdering = prop.Value.GetBoolean(); + continue; + } + if (prop.NameEquals("ignoredQueryParameters"u8)) + { + ignoredQueryParameters = prop.Value.GetString(); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new CustomDefaultMatcher( + compareBodies, + excludedHeaders, + ignoredHeaders, + ignoreQueryOrdering, + ignoredQueryParameters, + additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(CustomDefaultMatcher)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + CustomDefaultMatcher IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual CustomDefaultMatcher PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeCustomDefaultMatcher(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(CustomDefaultMatcher)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + + /// The to serialize into . + public static implicit operator BinaryContent(CustomDefaultMatcher customDefaultMatcher) + { + if (customDefaultMatcher == null) + { + return null; + } + return BinaryContent.Create(customDefaultMatcher, ModelSerializationExtensions.WireOptions); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/CustomDefaultMatcher.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/CustomDefaultMatcher.cs new file mode 100644 index 0000000000..540165872d --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/CustomDefaultMatcher.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The CustomDefaultMatcher. + public partial class CustomDefaultMatcher + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + public CustomDefaultMatcher() + { + } + + /// Initializes a new instance of . + /// + /// + /// + /// + /// + /// Keeps track of any properties unknown to the library. + internal CustomDefaultMatcher(bool? compareBodies, string excludedHeaders, string ignoredHeaders, bool? ignoreQueryOrdering, string ignoredQueryParameters, IDictionary additionalBinaryDataProperties) + { + CompareBodies = compareBodies; + ExcludedHeaders = excludedHeaders; + IgnoredHeaders = ignoredHeaders; + IgnoreQueryOrdering = ignoreQueryOrdering; + IgnoredQueryParameters = ignoredQueryParameters; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets or sets the CompareBodies. + public bool? CompareBodies { get; set; } + + /// Gets or sets the ExcludedHeaders. + public string ExcludedHeaders { get; set; } + + /// Gets or sets the IgnoredHeaders. + public string IgnoredHeaders { get; set; } + + /// Gets or sets the IgnoreQueryOrdering. + public bool? IgnoreQueryOrdering { get; set; } + + /// Gets or sets the IgnoredQueryParameters. + public string IgnoredQueryParameters { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizer.Serialization.cs new file mode 100644 index 0000000000..2fef611890 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizer.Serialization.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The GeneralRegexSanitizer. + public partial class GeneralRegexSanitizer : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal GeneralRegexSanitizer() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(GeneralRegexSanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + writer.WritePropertyName("Body"u8); + writer.WriteObjectValue(Body, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + GeneralRegexSanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (GeneralRegexSanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(GeneralRegexSanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeGeneralRegexSanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static GeneralRegexSanitizer DeserializeGeneralRegexSanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + GeneralRegexSanitizerBody body = default; + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (prop.NameEquals("Body"u8)) + { + body = GeneralRegexSanitizerBody.DeserializeGeneralRegexSanitizerBody(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new GeneralRegexSanitizer(name, additionalBinaryDataProperties, body); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(GeneralRegexSanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + GeneralRegexSanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (GeneralRegexSanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeGeneralRegexSanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(GeneralRegexSanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizer.cs new file mode 100644 index 0000000000..8637da98b5 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizer.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The GeneralRegexSanitizer. + public partial class GeneralRegexSanitizer : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// is null. + public GeneralRegexSanitizer(GeneralRegexSanitizerBody body) : base(SanitizerType.GeneralRegexSanitizer) + { + Argument.AssertNotNull(body, nameof(body)); + + Body = body; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + /// + internal GeneralRegexSanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties, GeneralRegexSanitizerBody body) : base(name, additionalBinaryDataProperties) + { + Body = body; + } + + /// Gets the Body. + public GeneralRegexSanitizerBody Body { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizerBody.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizerBody.Serialization.cs new file mode 100644 index 0000000000..3ae47c9d70 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizerBody.Serialization.cs @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The GeneralRegexSanitizerBody. + public partial class GeneralRegexSanitizerBody : IJsonModel + { + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(GeneralRegexSanitizerBody)} does not support writing '{format}' format."); + } + if (Optional.IsDefined(Value)) + { + writer.WritePropertyName("value"u8); + writer.WriteStringValue(Value); + } + if (Optional.IsDefined(Regex)) + { + writer.WritePropertyName("regex"u8); + writer.WriteStringValue(Regex); + } + if (Optional.IsDefined(GroupForReplace)) + { + writer.WritePropertyName("groupForReplace"u8); + writer.WriteStringValue(GroupForReplace); + } + if (Optional.IsDefined(Condition)) + { + writer.WritePropertyName("condition"u8); + writer.WriteObjectValue(Condition, options); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + GeneralRegexSanitizerBody IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual GeneralRegexSanitizerBody JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(GeneralRegexSanitizerBody)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeGeneralRegexSanitizerBody(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static GeneralRegexSanitizerBody DeserializeGeneralRegexSanitizerBody(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string value = default; + string regex = default; + string groupForReplace = default; + ApplyCondition condition = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("value"u8)) + { + value = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("regex"u8)) + { + regex = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("groupForReplace"u8)) + { + groupForReplace = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("condition"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + condition = ApplyCondition.DeserializeApplyCondition(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new GeneralRegexSanitizerBody(value, regex, groupForReplace, condition, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(GeneralRegexSanitizerBody)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + GeneralRegexSanitizerBody IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual GeneralRegexSanitizerBody PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeGeneralRegexSanitizerBody(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(GeneralRegexSanitizerBody)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizerBody.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizerBody.cs new file mode 100644 index 0000000000..1a7f572921 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralRegexSanitizerBody.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The GeneralRegexSanitizerBody. + public partial class GeneralRegexSanitizerBody + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + public GeneralRegexSanitizerBody() + { + } + + /// Initializes a new instance of . + /// + /// + /// + /// + /// Keeps track of any properties unknown to the library. + internal GeneralRegexSanitizerBody(string value, string regex, string groupForReplace, ApplyCondition condition, IDictionary additionalBinaryDataProperties) + { + Value = value; + Regex = regex; + GroupForReplace = groupForReplace; + Condition = condition; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets or sets the Value. + public string Value { get; set; } + + /// Gets or sets the Regex. + public string Regex { get; set; } + + /// Gets or sets the GroupForReplace. + public string GroupForReplace { get; set; } + + /// Gets or sets the Condition. + public ApplyCondition Condition { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizer.Serialization.cs new file mode 100644 index 0000000000..f89cb6169d --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizer.Serialization.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The GeneralStringSanitizer. + public partial class GeneralStringSanitizer : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal GeneralStringSanitizer() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(GeneralStringSanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + writer.WritePropertyName("Body"u8); + writer.WriteObjectValue(Body, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + GeneralStringSanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (GeneralStringSanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(GeneralStringSanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeGeneralStringSanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static GeneralStringSanitizer DeserializeGeneralStringSanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + GeneralStringSanitizerBody body = default; + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (prop.NameEquals("Body"u8)) + { + body = GeneralStringSanitizerBody.DeserializeGeneralStringSanitizerBody(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new GeneralStringSanitizer(name, additionalBinaryDataProperties, body); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(GeneralStringSanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + GeneralStringSanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (GeneralStringSanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeGeneralStringSanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(GeneralStringSanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizer.cs new file mode 100644 index 0000000000..85a63eb855 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizer.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The GeneralStringSanitizer. + public partial class GeneralStringSanitizer : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// is null. + public GeneralStringSanitizer(GeneralStringSanitizerBody body) : base(SanitizerType.GeneralStringSanitizer) + { + Argument.AssertNotNull(body, nameof(body)); + + Body = body; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + /// + internal GeneralStringSanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties, GeneralStringSanitizerBody body) : base(name, additionalBinaryDataProperties) + { + Body = body; + } + + /// Gets the Body. + public GeneralStringSanitizerBody Body { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizerBody.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizerBody.Serialization.cs new file mode 100644 index 0000000000..efbadc1582 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizerBody.Serialization.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The GeneralStringSanitizerBody. + public partial class GeneralStringSanitizerBody : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal GeneralStringSanitizerBody() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(GeneralStringSanitizerBody)} does not support writing '{format}' format."); + } + writer.WritePropertyName("target"u8); + writer.WriteStringValue(Target); + if (Optional.IsDefined(Value)) + { + writer.WritePropertyName("value"u8); + writer.WriteStringValue(Value); + } + if (Optional.IsDefined(Condition)) + { + writer.WritePropertyName("condition"u8); + writer.WriteObjectValue(Condition, options); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + GeneralStringSanitizerBody IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual GeneralStringSanitizerBody JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(GeneralStringSanitizerBody)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeGeneralStringSanitizerBody(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static GeneralStringSanitizerBody DeserializeGeneralStringSanitizerBody(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string target = default; + string value = default; + ApplyCondition condition = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("target"u8)) + { + target = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("value"u8)) + { + value = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("condition"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + condition = ApplyCondition.DeserializeApplyCondition(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new GeneralStringSanitizerBody(target, value, condition, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(GeneralStringSanitizerBody)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + GeneralStringSanitizerBody IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual GeneralStringSanitizerBody PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeGeneralStringSanitizerBody(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(GeneralStringSanitizerBody)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizerBody.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizerBody.cs new file mode 100644 index 0000000000..8613c1072c --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/GeneralStringSanitizerBody.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The GeneralStringSanitizerBody. + public partial class GeneralStringSanitizerBody + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + /// is null. + public GeneralStringSanitizerBody(string target) + { + Argument.AssertNotNull(target, nameof(target)); + + Target = target; + } + + /// Initializes a new instance of . + /// + /// + /// + /// Keeps track of any properties unknown to the library. + internal GeneralStringSanitizerBody(string target, string value, ApplyCondition condition, IDictionary additionalBinaryDataProperties) + { + Target = target; + Value = value; + Condition = condition; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the Target. + public string Target { get; } + + /// Gets or sets the Value. + public string Value { get; set; } + + /// Gets or sets the Condition. + public ApplyCondition Condition { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizer.Serialization.cs new file mode 100644 index 0000000000..58b79dba71 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizer.Serialization.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The HeaderRegexSanitizer. + public partial class HeaderRegexSanitizer : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal HeaderRegexSanitizer() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(HeaderRegexSanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + writer.WritePropertyName("Body"u8); + writer.WriteObjectValue(Body, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + HeaderRegexSanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (HeaderRegexSanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(HeaderRegexSanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeHeaderRegexSanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static HeaderRegexSanitizer DeserializeHeaderRegexSanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + HeaderRegexSanitizerBody body = default; + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (prop.NameEquals("Body"u8)) + { + body = HeaderRegexSanitizerBody.DeserializeHeaderRegexSanitizerBody(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new HeaderRegexSanitizer(name, additionalBinaryDataProperties, body); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(HeaderRegexSanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + HeaderRegexSanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (HeaderRegexSanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeHeaderRegexSanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(HeaderRegexSanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizer.cs new file mode 100644 index 0000000000..6b6f45e0fd --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizer.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The HeaderRegexSanitizer. + public partial class HeaderRegexSanitizer : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// is null. + public HeaderRegexSanitizer(HeaderRegexSanitizerBody body) : base(SanitizerType.HeaderRegexSanitizer) + { + Argument.AssertNotNull(body, nameof(body)); + + Body = body; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + /// + internal HeaderRegexSanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties, HeaderRegexSanitizerBody body) : base(name, additionalBinaryDataProperties) + { + Body = body; + } + + /// Gets the Body. + public HeaderRegexSanitizerBody Body { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizerBody.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizerBody.Serialization.cs new file mode 100644 index 0000000000..d667743b5a --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizerBody.Serialization.cs @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The HeaderRegexSanitizerBody. + public partial class HeaderRegexSanitizerBody : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal HeaderRegexSanitizerBody() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(HeaderRegexSanitizerBody)} does not support writing '{format}' format."); + } + writer.WritePropertyName("key"u8); + writer.WriteStringValue(Key); + if (Optional.IsDefined(Value)) + { + writer.WritePropertyName("value"u8); + writer.WriteStringValue(Value); + } + if (Optional.IsDefined(Regex)) + { + writer.WritePropertyName("regex"u8); + writer.WriteStringValue(Regex); + } + if (Optional.IsDefined(GroupForReplace)) + { + writer.WritePropertyName("groupForReplace"u8); + writer.WriteStringValue(GroupForReplace); + } + if (Optional.IsDefined(Condition)) + { + writer.WritePropertyName("condition"u8); + writer.WriteObjectValue(Condition, options); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + HeaderRegexSanitizerBody IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual HeaderRegexSanitizerBody JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(HeaderRegexSanitizerBody)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeHeaderRegexSanitizerBody(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static HeaderRegexSanitizerBody DeserializeHeaderRegexSanitizerBody(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string key = default; + string value = default; + string regex = default; + string groupForReplace = default; + ApplyCondition condition = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("key"u8)) + { + key = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("value"u8)) + { + value = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("regex"u8)) + { + regex = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("groupForReplace"u8)) + { + groupForReplace = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("condition"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + condition = ApplyCondition.DeserializeApplyCondition(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new HeaderRegexSanitizerBody( + key, + value, + regex, + groupForReplace, + condition, + additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(HeaderRegexSanitizerBody)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + HeaderRegexSanitizerBody IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual HeaderRegexSanitizerBody PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeHeaderRegexSanitizerBody(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(HeaderRegexSanitizerBody)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizerBody.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizerBody.cs new file mode 100644 index 0000000000..c8cf3e3c03 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderRegexSanitizerBody.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The HeaderRegexSanitizerBody. + public partial class HeaderRegexSanitizerBody + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + /// is null. + public HeaderRegexSanitizerBody(string key) + { + Argument.AssertNotNull(key, nameof(key)); + + Key = key; + } + + /// Initializes a new instance of . + /// + /// + /// + /// + /// + /// Keeps track of any properties unknown to the library. + internal HeaderRegexSanitizerBody(string key, string value, string regex, string groupForReplace, ApplyCondition condition, IDictionary additionalBinaryDataProperties) + { + Key = key; + Value = value; + Regex = regex; + GroupForReplace = groupForReplace; + Condition = condition; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the Key. + public string Key { get; } + + /// Gets or sets the Value. + public string Value { get; set; } + + /// Gets or sets the Regex. + public string Regex { get; set; } + + /// Gets or sets the GroupForReplace. + public string GroupForReplace { get; set; } + + /// Gets or sets the Condition. + public ApplyCondition Condition { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizer.Serialization.cs new file mode 100644 index 0000000000..9dcdc126e5 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizer.Serialization.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The HeaderStringSanitizer. + public partial class HeaderStringSanitizer : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal HeaderStringSanitizer() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(HeaderStringSanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + writer.WritePropertyName("Body"u8); + writer.WriteObjectValue(Body, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + HeaderStringSanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (HeaderStringSanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(HeaderStringSanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeHeaderStringSanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static HeaderStringSanitizer DeserializeHeaderStringSanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + HeaderStringSanitizerBody body = default; + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (prop.NameEquals("Body"u8)) + { + body = HeaderStringSanitizerBody.DeserializeHeaderStringSanitizerBody(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new HeaderStringSanitizer(name, additionalBinaryDataProperties, body); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(HeaderStringSanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + HeaderStringSanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (HeaderStringSanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeHeaderStringSanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(HeaderStringSanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizer.cs new file mode 100644 index 0000000000..0a9eb7227a --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizer.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The HeaderStringSanitizer. + public partial class HeaderStringSanitizer : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// is null. + public HeaderStringSanitizer(HeaderStringSanitizerBody body) : base(SanitizerType.HeaderStringSanitizer) + { + Argument.AssertNotNull(body, nameof(body)); + + Body = body; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + /// + internal HeaderStringSanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties, HeaderStringSanitizerBody body) : base(name, additionalBinaryDataProperties) + { + Body = body; + } + + /// Gets the Body. + public HeaderStringSanitizerBody Body { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizerBody.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizerBody.Serialization.cs new file mode 100644 index 0000000000..d8713205fb --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizerBody.Serialization.cs @@ -0,0 +1,178 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The HeaderStringSanitizerBody. + public partial class HeaderStringSanitizerBody : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal HeaderStringSanitizerBody() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(HeaderStringSanitizerBody)} does not support writing '{format}' format."); + } + writer.WritePropertyName("key"u8); + writer.WriteStringValue(Key); + writer.WritePropertyName("target"u8); + writer.WriteStringValue(Target); + if (Optional.IsDefined(Value)) + { + writer.WritePropertyName("value"u8); + writer.WriteStringValue(Value); + } + if (Optional.IsDefined(Condition)) + { + writer.WritePropertyName("condition"u8); + writer.WriteObjectValue(Condition, options); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + HeaderStringSanitizerBody IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual HeaderStringSanitizerBody JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(HeaderStringSanitizerBody)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeHeaderStringSanitizerBody(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static HeaderStringSanitizerBody DeserializeHeaderStringSanitizerBody(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string key = default; + string target = default; + string value = default; + ApplyCondition condition = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("key"u8)) + { + key = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("target"u8)) + { + target = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("value"u8)) + { + value = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("condition"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + condition = ApplyCondition.DeserializeApplyCondition(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new HeaderStringSanitizerBody(key, target, value, condition, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(HeaderStringSanitizerBody)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + HeaderStringSanitizerBody IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual HeaderStringSanitizerBody PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeHeaderStringSanitizerBody(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(HeaderStringSanitizerBody)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizerBody.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizerBody.cs new file mode 100644 index 0000000000..bba1ae0d62 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/HeaderStringSanitizerBody.cs @@ -0,0 +1,61 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The HeaderStringSanitizerBody. + public partial class HeaderStringSanitizerBody + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + /// + /// or is null. + public HeaderStringSanitizerBody(string key, string target) + { + Argument.AssertNotNull(key, nameof(key)); + Argument.AssertNotNull(target, nameof(target)); + + Key = key; + Target = target; + } + + /// Initializes a new instance of . + /// + /// + /// + /// + /// Keeps track of any properties unknown to the library. + internal HeaderStringSanitizerBody(string key, string target, string value, ApplyCondition condition, IDictionary additionalBinaryDataProperties) + { + Key = key; + Target = target; + Value = value; + Condition = condition; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the Key. + public string Key { get; } + + /// Gets the Target. + public string Target { get; } + + /// Gets or sets the Value. + public string Value { get; set; } + + /// Gets or sets the Condition. + public ApplyCondition Condition { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/MatcherType.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/MatcherType.Serialization.cs new file mode 100644 index 0000000000..cd7f9a0dbd --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/MatcherType.Serialization.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; + +namespace Azure.Mcp.Tests.Generated.Models +{ + internal static partial class MatcherTypeExtensions + { + /// The value to serialize. + public static string ToSerialString(this MatcherType value) => value switch + { + MatcherType.BodilessMatcher => "BodilessMatcher", + MatcherType.CustomDefaultMatcher => "CustomDefaultMatcher", + MatcherType.HeaderlessMatcher => "HeaderlessMatcher", + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown MatcherType value.") + }; + + /// The value to deserialize. + public static MatcherType ToMatcherType(this string value) + { + if (StringComparer.OrdinalIgnoreCase.Equals(value, "BodilessMatcher")) + { + return MatcherType.BodilessMatcher; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "CustomDefaultMatcher")) + { + return MatcherType.CustomDefaultMatcher; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "HeaderlessMatcher")) + { + return MatcherType.HeaderlessMatcher; + } + throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown MatcherType value."); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/MatcherType.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/MatcherType.cs new file mode 100644 index 0000000000..35055c2854 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/MatcherType.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// + public enum MatcherType + { + /// BodilessMatcher. + BodilessMatcher, + /// CustomDefaultMatcher. + CustomDefaultMatcher, + /// HeaderlessMatcher. + HeaderlessMatcher + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/MicrosoftClientModelTestFrameworkContext.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/MicrosoftClientModelTestFrameworkContext.cs new file mode 100644 index 0000000000..bc3ca546dd --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/MicrosoftClientModelTestFrameworkContext.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System.ClientModel.Primitives; +using Azure.Mcp.Tests.Generated.Models; + +namespace Azure.Mcp.Tests.Generated.Internal +{ + /// + /// Context class which will be filled in by the System.ClientModel.SourceGeneration. + /// For more information + /// + [ModelReaderWriterBuildable(typeof(ApplyCondition))] + [ModelReaderWriterBuildable(typeof(BodyKeySanitizer))] + [ModelReaderWriterBuildable(typeof(BodyKeySanitizerBody))] + [ModelReaderWriterBuildable(typeof(BodyRegexSanitizer))] + [ModelReaderWriterBuildable(typeof(BodyRegexSanitizerBody))] + [ModelReaderWriterBuildable(typeof(BodyStringSanitizer))] + [ModelReaderWriterBuildable(typeof(BodyStringSanitizerBody))] + [ModelReaderWriterBuildable(typeof(CustomDefaultMatcher))] + [ModelReaderWriterBuildable(typeof(GeneralRegexSanitizer))] + [ModelReaderWriterBuildable(typeof(GeneralRegexSanitizerBody))] + [ModelReaderWriterBuildable(typeof(GeneralStringSanitizer))] + [ModelReaderWriterBuildable(typeof(GeneralStringSanitizerBody))] + [ModelReaderWriterBuildable(typeof(HeaderRegexSanitizer))] + [ModelReaderWriterBuildable(typeof(HeaderRegexSanitizerBody))] + [ModelReaderWriterBuildable(typeof(HeaderStringSanitizer))] + [ModelReaderWriterBuildable(typeof(HeaderStringSanitizerBody))] + [ModelReaderWriterBuildable(typeof(OAuthResponseSanitizer))] + [ModelReaderWriterBuildable(typeof(RecordingOptions))] + [ModelReaderWriterBuildable(typeof(RegexEntrySanitizer))] + [ModelReaderWriterBuildable(typeof(RegexEntrySanitizerBody))] + [ModelReaderWriterBuildable(typeof(RemovedSanitizers))] + [ModelReaderWriterBuildable(typeof(RemoveHeaderSanitizer))] + [ModelReaderWriterBuildable(typeof(RemoveHeaderSanitizerBody))] + [ModelReaderWriterBuildable(typeof(SanitizerAddition))] + [ModelReaderWriterBuildable(typeof(SanitizerList))] + [ModelReaderWriterBuildable(typeof(TestProxyCertificate))] + [ModelReaderWriterBuildable(typeof(TestProxyStartInformation))] + [ModelReaderWriterBuildable(typeof(TransportCustomizations))] + [ModelReaderWriterBuildable(typeof(UnknownSanitizerAddition))] + [ModelReaderWriterBuildable(typeof(UriRegexSanitizer))] + [ModelReaderWriterBuildable(typeof(UriRegexSanitizerBody))] + [ModelReaderWriterBuildable(typeof(UriStringSanitizer))] + [ModelReaderWriterBuildable(typeof(UriStringSanitizerBody))] + [ModelReaderWriterBuildable(typeof(UriSubscriptionIdSanitizer))] + [ModelReaderWriterBuildable(typeof(UriSubscriptionIdSanitizerBody))] + public partial class MicrosoftClientModelTestFrameworkContext : ModelReaderWriterContext + { + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/OAuthResponseSanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/OAuthResponseSanitizer.Serialization.cs new file mode 100644 index 0000000000..12b13938f9 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/OAuthResponseSanitizer.Serialization.cs @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The OAuthResponseSanitizer. + public partial class OAuthResponseSanitizer : SanitizerAddition, IJsonModel + { + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(OAuthResponseSanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + OAuthResponseSanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (OAuthResponseSanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(OAuthResponseSanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeOAuthResponseSanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static OAuthResponseSanitizer DeserializeOAuthResponseSanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new OAuthResponseSanitizer(name, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(OAuthResponseSanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + OAuthResponseSanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (OAuthResponseSanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeOAuthResponseSanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(OAuthResponseSanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/OAuthResponseSanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/OAuthResponseSanitizer.cs new file mode 100644 index 0000000000..7fd3113061 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/OAuthResponseSanitizer.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The OAuthResponseSanitizer. + public partial class OAuthResponseSanitizer : SanitizerAddition + { + /// Initializes a new instance of . + public OAuthResponseSanitizer() : base(SanitizerType.OAuthResponseSanitizer) + { + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + internal OAuthResponseSanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties) : base(name, additionalBinaryDataProperties) + { + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RecordingOptions.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RecordingOptions.Serialization.cs new file mode 100644 index 0000000000..7fd45b1d2e --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RecordingOptions.Serialization.cs @@ -0,0 +1,198 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The RecordingOptions. + public partial class RecordingOptions : IJsonModel + { + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(RecordingOptions)} does not support writing '{format}' format."); + } + if (Optional.IsDefined(HandleRedirects)) + { + writer.WritePropertyName("HandleRedirects"u8); + writer.WriteBooleanValue(HandleRedirects.Value); + } + if (Optional.IsDefined(ContextDirectory)) + { + writer.WritePropertyName("ContextDirectory"u8); + writer.WriteStringValue(ContextDirectory); + } + if (Optional.IsDefined(AssetsStore)) + { + writer.WritePropertyName("AssetsStore"u8); + writer.WriteStringValue(AssetsStore.Value.ToSerialString()); + } + if (Optional.IsDefined(Transport)) + { + writer.WritePropertyName("Transport"u8); + writer.WriteObjectValue(Transport, options); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + RecordingOptions IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual RecordingOptions JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(RecordingOptions)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeRecordingOptions(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static RecordingOptions DeserializeRecordingOptions(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + bool? handleRedirects = default; + string contextDirectory = default; + StoreType? assetsStore = default; + TransportCustomizations transport = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("HandleRedirects"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + handleRedirects = prop.Value.GetBoolean(); + continue; + } + if (prop.NameEquals("ContextDirectory"u8)) + { + contextDirectory = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("AssetsStore"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + assetsStore = prop.Value.GetString().ToStoreType(); + continue; + } + if (prop.NameEquals("Transport"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + transport = TransportCustomizations.DeserializeTransportCustomizations(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new RecordingOptions(handleRedirects, contextDirectory, assetsStore, transport, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(RecordingOptions)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + RecordingOptions IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual RecordingOptions PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeRecordingOptions(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(RecordingOptions)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + + /// The to serialize into . + public static implicit operator BinaryContent(RecordingOptions recordingOptions) + { + if (recordingOptions == null) + { + return null; + } + return BinaryContent.Create(recordingOptions, ModelSerializationExtensions.WireOptions); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RecordingOptions.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RecordingOptions.cs new file mode 100644 index 0000000000..5a1f1ff227 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RecordingOptions.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The RecordingOptions. + public partial class RecordingOptions + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + public RecordingOptions() + { + } + + /// Initializes a new instance of . + /// + /// + /// + /// + /// Keeps track of any properties unknown to the library. + internal RecordingOptions(bool? handleRedirects, string contextDirectory, StoreType? assetsStore, TransportCustomizations transport, IDictionary additionalBinaryDataProperties) + { + HandleRedirects = handleRedirects; + ContextDirectory = contextDirectory; + AssetsStore = assetsStore; + Transport = transport; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets or sets the HandleRedirects. + public bool? HandleRedirects { get; set; } + + /// Gets or sets the ContextDirectory. + public string ContextDirectory { get; set; } + + /// Gets or sets the AssetsStore. + public StoreType? AssetsStore { get; set; } + + /// Gets or sets the Transport. + public TransportCustomizations Transport { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizer.Serialization.cs new file mode 100644 index 0000000000..90246d339a --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizer.Serialization.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The RegexEntrySanitizer. + public partial class RegexEntrySanitizer : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal RegexEntrySanitizer() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(RegexEntrySanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + writer.WritePropertyName("Body"u8); + writer.WriteObjectValue(Body, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + RegexEntrySanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (RegexEntrySanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(RegexEntrySanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeRegexEntrySanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static RegexEntrySanitizer DeserializeRegexEntrySanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + RegexEntrySanitizerBody body = default; + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (prop.NameEquals("Body"u8)) + { + body = RegexEntrySanitizerBody.DeserializeRegexEntrySanitizerBody(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new RegexEntrySanitizer(name, additionalBinaryDataProperties, body); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(RegexEntrySanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + RegexEntrySanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (RegexEntrySanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeRegexEntrySanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(RegexEntrySanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizer.cs new file mode 100644 index 0000000000..c6efbc7c72 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizer.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The RegexEntrySanitizer. + public partial class RegexEntrySanitizer : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// is null. + public RegexEntrySanitizer(RegexEntrySanitizerBody body) : base(SanitizerType.RegexEntrySanitizer) + { + Argument.AssertNotNull(body, nameof(body)); + + Body = body; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + /// + internal RegexEntrySanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties, RegexEntrySanitizerBody body) : base(name, additionalBinaryDataProperties) + { + Body = body; + } + + /// Gets the Body. + public RegexEntrySanitizerBody Body { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizerBody.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizerBody.Serialization.cs new file mode 100644 index 0000000000..c061dd4c0e --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizerBody.Serialization.cs @@ -0,0 +1,152 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The RegexEntrySanitizerBody. + public partial class RegexEntrySanitizerBody : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal RegexEntrySanitizerBody() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(RegexEntrySanitizerBody)} does not support writing '{format}' format."); + } + writer.WritePropertyName("target"u8); + writer.WriteStringValue(Target.ToSerialString()); + writer.WritePropertyName("regex"u8); + writer.WriteStringValue(Regex); + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + RegexEntrySanitizerBody IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual RegexEntrySanitizerBody JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(RegexEntrySanitizerBody)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeRegexEntrySanitizerBody(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static RegexEntrySanitizerBody DeserializeRegexEntrySanitizerBody(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + RegexEntryValues target = default; + string regex = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("target"u8)) + { + target = prop.Value.GetString().ToRegexEntryValues(); + continue; + } + if (prop.NameEquals("regex"u8)) + { + regex = prop.Value.GetString(); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new RegexEntrySanitizerBody(target, regex, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(RegexEntrySanitizerBody)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + RegexEntrySanitizerBody IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual RegexEntrySanitizerBody PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeRegexEntrySanitizerBody(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(RegexEntrySanitizerBody)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizerBody.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizerBody.cs new file mode 100644 index 0000000000..288b5cd131 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntrySanitizerBody.cs @@ -0,0 +1,50 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The RegexEntrySanitizerBody. + public partial class RegexEntrySanitizerBody + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + /// + /// is null. + public RegexEntrySanitizerBody(RegexEntryValues target, string regex) + { + Argument.AssertNotNull(regex, nameof(regex)); + + Target = target; + Regex = regex; + } + + /// Initializes a new instance of . + /// + /// + /// Keeps track of any properties unknown to the library. + internal RegexEntrySanitizerBody(RegexEntryValues target, string regex, IDictionary additionalBinaryDataProperties) + { + Target = target; + Regex = regex; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the Target. + public RegexEntryValues Target { get; } + + /// Gets the Regex. + public string Regex { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntryValues.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntryValues.Serialization.cs new file mode 100644 index 0000000000..0f737915f7 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntryValues.Serialization.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; + +namespace Azure.Mcp.Tests.Generated.Models +{ + internal static partial class RegexEntryValuesExtensions + { + /// The value to serialize. + public static string ToSerialString(this RegexEntryValues value) => value switch + { + RegexEntryValues.Body => "body", + RegexEntryValues.Header => "header", + RegexEntryValues.Uri => "uri", + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown RegexEntryValues value.") + }; + + /// The value to deserialize. + public static RegexEntryValues ToRegexEntryValues(this string value) + { + if (StringComparer.OrdinalIgnoreCase.Equals(value, "body")) + { + return RegexEntryValues.Body; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "header")) + { + return RegexEntryValues.Header; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "uri")) + { + return RegexEntryValues.Uri; + } + throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown RegexEntryValues value."); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntryValues.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntryValues.cs new file mode 100644 index 0000000000..9b248d8111 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RegexEntryValues.cs @@ -0,0 +1,20 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// + public enum RegexEntryValues + { + /// Body. + Body, + /// Header. + Header, + /// Uri. + Uri + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizer.Serialization.cs new file mode 100644 index 0000000000..5bc5ba395a --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizer.Serialization.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The RemoveHeaderSanitizer. + public partial class RemoveHeaderSanitizer : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal RemoveHeaderSanitizer() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(RemoveHeaderSanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + writer.WritePropertyName("Body"u8); + writer.WriteObjectValue(Body, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + RemoveHeaderSanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (RemoveHeaderSanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(RemoveHeaderSanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeRemoveHeaderSanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static RemoveHeaderSanitizer DeserializeRemoveHeaderSanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + RemoveHeaderSanitizerBody body = default; + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (prop.NameEquals("Body"u8)) + { + body = RemoveHeaderSanitizerBody.DeserializeRemoveHeaderSanitizerBody(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new RemoveHeaderSanitizer(name, additionalBinaryDataProperties, body); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(RemoveHeaderSanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + RemoveHeaderSanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (RemoveHeaderSanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeRemoveHeaderSanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(RemoveHeaderSanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizer.cs new file mode 100644 index 0000000000..84c5e1df2e --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizer.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The RemoveHeaderSanitizer. + public partial class RemoveHeaderSanitizer : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// is null. + public RemoveHeaderSanitizer(RemoveHeaderSanitizerBody body) : base(SanitizerType.RemoveHeaderSanitizer) + { + Argument.AssertNotNull(body, nameof(body)); + + Body = body; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + /// + internal RemoveHeaderSanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties, RemoveHeaderSanitizerBody body) : base(name, additionalBinaryDataProperties) + { + Body = body; + } + + /// Gets the Body. + public RemoveHeaderSanitizerBody Body { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizerBody.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizerBody.Serialization.cs new file mode 100644 index 0000000000..495927e808 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizerBody.Serialization.cs @@ -0,0 +1,143 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The RemoveHeaderSanitizerBody. + public partial class RemoveHeaderSanitizerBody : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal RemoveHeaderSanitizerBody() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(RemoveHeaderSanitizerBody)} does not support writing '{format}' format."); + } + writer.WritePropertyName("headersForRemoval"u8); + writer.WriteStringValue(HeadersForRemoval); + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + RemoveHeaderSanitizerBody IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual RemoveHeaderSanitizerBody JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(RemoveHeaderSanitizerBody)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeRemoveHeaderSanitizerBody(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static RemoveHeaderSanitizerBody DeserializeRemoveHeaderSanitizerBody(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string headersForRemoval = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("headersForRemoval"u8)) + { + headersForRemoval = prop.Value.GetString(); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new RemoveHeaderSanitizerBody(headersForRemoval, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(RemoveHeaderSanitizerBody)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + RemoveHeaderSanitizerBody IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual RemoveHeaderSanitizerBody PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeRemoveHeaderSanitizerBody(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(RemoveHeaderSanitizerBody)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizerBody.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizerBody.cs new file mode 100644 index 0000000000..d303f9ea03 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemoveHeaderSanitizerBody.cs @@ -0,0 +1,43 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The RemoveHeaderSanitizerBody. + public partial class RemoveHeaderSanitizerBody + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + /// is null. + public RemoveHeaderSanitizerBody(string headersForRemoval) + { + Argument.AssertNotNull(headersForRemoval, nameof(headersForRemoval)); + + HeadersForRemoval = headersForRemoval; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + internal RemoveHeaderSanitizerBody(string headersForRemoval, IDictionary additionalBinaryDataProperties) + { + HeadersForRemoval = headersForRemoval; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the HeadersForRemoval. + public string HeadersForRemoval { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemovedSanitizers.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemovedSanitizers.Serialization.cs new file mode 100644 index 0000000000..aefc30a4a8 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemovedSanitizers.Serialization.cs @@ -0,0 +1,175 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The RemovedSanitizers. + public partial class RemovedSanitizers : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal RemovedSanitizers() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(RemovedSanitizers)} does not support writing '{format}' format."); + } + writer.WritePropertyName("Removed"u8); + writer.WriteStartArray(); + foreach (string item in Removed) + { + if (item == null) + { + writer.WriteNullValue(); + continue; + } + writer.WriteStringValue(item); + } + writer.WriteEndArray(); + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + RemovedSanitizers IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual RemovedSanitizers JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(RemovedSanitizers)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeRemovedSanitizers(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static RemovedSanitizers DeserializeRemovedSanitizers(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + IList removed = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Removed"u8)) + { + List array = new List(); + foreach (var item in prop.Value.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.Null) + { + array.Add(null); + } + else + { + array.Add(item.GetString()); + } + } + removed = array; + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new RemovedSanitizers(removed, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(RemovedSanitizers)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + RemovedSanitizers IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual RemovedSanitizers PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeRemovedSanitizers(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(RemovedSanitizers)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + + /// The to deserialize the from. + public static explicit operator RemovedSanitizers(ClientResult result) + { + using PipelineResponse response = result.GetRawResponse(); + using JsonDocument document = JsonDocument.Parse(response.Content); + return DeserializeRemovedSanitizers(document.RootElement, ModelSerializationExtensions.WireOptions); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemovedSanitizers.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemovedSanitizers.cs new file mode 100644 index 0000000000..90596701c4 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/RemovedSanitizers.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using System.Linq; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The RemovedSanitizers. + public partial class RemovedSanitizers + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + internal RemovedSanitizers(IEnumerable removed) + { + Removed = removed.ToList(); + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + internal RemovedSanitizers(IList removed, IDictionary additionalBinaryDataProperties) + { + Removed = removed; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the Removed. + public IList Removed { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerAddition.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerAddition.Serialization.cs new file mode 100644 index 0000000000..56a8587380 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerAddition.Serialization.cs @@ -0,0 +1,165 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// + /// The SanitizerAddition. + /// Please note this is the abstract base class. The derived classes available for instantiation are: , , , , , , , , , , , , and . + /// + [PersistableModelProxy(typeof(UnknownSanitizerAddition))] + public abstract partial class SanitizerAddition : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal SanitizerAddition() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(SanitizerAddition)} does not support writing '{format}' format."); + } + writer.WritePropertyName("Name"u8); + writer.WriteStringValue(Name.ToSerialString()); + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + SanitizerAddition IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(SanitizerAddition)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeSanitizerAddition(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static SanitizerAddition DeserializeSanitizerAddition(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + if (element.TryGetProperty("Name"u8, out JsonElement discriminator)) + { + switch (discriminator.GetString()) + { + case "BodyKeySanitizer": + return BodyKeySanitizer.DeserializeBodyKeySanitizer(element, options); + case "BodyRegexSanitizer": + return BodyRegexSanitizer.DeserializeBodyRegexSanitizer(element, options); + case "BodyStringSanitizer": + return BodyStringSanitizer.DeserializeBodyStringSanitizer(element, options); + case "GeneralRegexSanitizer": + return GeneralRegexSanitizer.DeserializeGeneralRegexSanitizer(element, options); + case "GeneralStringSanitizer": + return GeneralStringSanitizer.DeserializeGeneralStringSanitizer(element, options); + case "HeaderRegexSanitizer": + return HeaderRegexSanitizer.DeserializeHeaderRegexSanitizer(element, options); + case "HeaderStringSanitizer": + return HeaderStringSanitizer.DeserializeHeaderStringSanitizer(element, options); + case "OAuthResponseSanitizer": + return OAuthResponseSanitizer.DeserializeOAuthResponseSanitizer(element, options); + case "RegexEntrySanitizer": + return RegexEntrySanitizer.DeserializeRegexEntrySanitizer(element, options); + case "RemoveHeaderSanitizer": + return RemoveHeaderSanitizer.DeserializeRemoveHeaderSanitizer(element, options); + case "UriRegexSanitizer": + return UriRegexSanitizer.DeserializeUriRegexSanitizer(element, options); + case "UriStringSanitizer": + return UriStringSanitizer.DeserializeUriStringSanitizer(element, options); + case "UriSubscriptionIdSanitizer": + return UriSubscriptionIdSanitizer.DeserializeUriSubscriptionIdSanitizer(element, options); + } + } + return UnknownSanitizerAddition.DeserializeUnknownSanitizerAddition(element, options); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(SanitizerAddition)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + SanitizerAddition IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeSanitizerAddition(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(SanitizerAddition)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerAddition.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerAddition.cs new file mode 100644 index 0000000000..49f0918866 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerAddition.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// + /// The SanitizerAddition. + /// Please note this is the abstract base class. The derived classes available for instantiation are: , , , , , , , , , , , , and . + /// + public abstract partial class SanitizerAddition + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + private protected SanitizerAddition(SanitizerType name) + { + Name = name; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + internal SanitizerAddition(SanitizerType name, IDictionary additionalBinaryDataProperties) + { + Name = name; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets or sets the Name. + internal SanitizerType Name { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerList.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerList.Serialization.cs new file mode 100644 index 0000000000..5688a5e99e --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerList.Serialization.cs @@ -0,0 +1,177 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The SanitizerList. + public partial class SanitizerList : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal SanitizerList() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(SanitizerList)} does not support writing '{format}' format."); + } + writer.WritePropertyName("Sanitizers"u8); + writer.WriteStartArray(); + foreach (string item in Sanitizers) + { + if (item == null) + { + writer.WriteNullValue(); + continue; + } + writer.WriteStringValue(item); + } + writer.WriteEndArray(); + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + SanitizerList IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual SanitizerList JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(SanitizerList)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeSanitizerList(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static SanitizerList DeserializeSanitizerList(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + IList sanitizers = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Sanitizers"u8)) + { + List array = new List(); + foreach (var item in prop.Value.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.Null) + { + array.Add(null); + } + else + { + array.Add(item.GetString()); + } + } + sanitizers = array; + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new SanitizerList(sanitizers, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(SanitizerList)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + SanitizerList IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual SanitizerList PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeSanitizerList(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(SanitizerList)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + + /// The to serialize into . + public static implicit operator BinaryContent(SanitizerList sanitizerList) + { + if (sanitizerList == null) + { + return null; + } + return BinaryContent.Create(sanitizerList, ModelSerializationExtensions.WireOptions); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerList.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerList.cs new file mode 100644 index 0000000000..c36207fed3 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerList.cs @@ -0,0 +1,44 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using System.Linq; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The SanitizerList. + public partial class SanitizerList + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + /// is null. + public SanitizerList(IEnumerable sanitizers) + { + Argument.AssertNotNull(sanitizers, nameof(sanitizers)); + + Sanitizers = sanitizers.ToList(); + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + internal SanitizerList(IList sanitizers, IDictionary additionalBinaryDataProperties) + { + Sanitizers = sanitizers; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the Sanitizers. + public IList Sanitizers { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerType.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerType.Serialization.cs new file mode 100644 index 0000000000..d39ab4315d --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerType.Serialization.cs @@ -0,0 +1,91 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; + +namespace Azure.Mcp.Tests.Generated.Models +{ + internal static partial class SanitizerTypeExtensions + { + /// The value to serialize. + public static string ToSerialString(this SanitizerType value) => value switch + { + SanitizerType.BodyKeySanitizer => "BodyKeySanitizer", + SanitizerType.BodyRegexSanitizer => "BodyRegexSanitizer", + SanitizerType.BodyStringSanitizer => "BodyStringSanitizer", + SanitizerType.GeneralRegexSanitizer => "GeneralRegexSanitizer", + SanitizerType.GeneralStringSanitizer => "GeneralStringSanitizer", + SanitizerType.HeaderRegexSanitizer => "HeaderRegexSanitizer", + SanitizerType.HeaderStringSanitizer => "HeaderStringSanitizer", + SanitizerType.OAuthResponseSanitizer => "OAuthResponseSanitizer", + SanitizerType.RegexEntrySanitizer => "RegexEntrySanitizer", + SanitizerType.RemoveHeaderSanitizer => "RemoveHeaderSanitizer", + SanitizerType.UriRegexSanitizer => "UriRegexSanitizer", + SanitizerType.UriStringSanitizer => "UriStringSanitizer", + SanitizerType.UriSubscriptionIdSanitizer => "UriSubscriptionIdSanitizer", + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown SanitizerType value.") + }; + + /// The value to deserialize. + public static SanitizerType ToSanitizerType(this string value) + { + if (StringComparer.OrdinalIgnoreCase.Equals(value, "BodyKeySanitizer")) + { + return SanitizerType.BodyKeySanitizer; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "BodyRegexSanitizer")) + { + return SanitizerType.BodyRegexSanitizer; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "BodyStringSanitizer")) + { + return SanitizerType.BodyStringSanitizer; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "GeneralRegexSanitizer")) + { + return SanitizerType.GeneralRegexSanitizer; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "GeneralStringSanitizer")) + { + return SanitizerType.GeneralStringSanitizer; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "HeaderRegexSanitizer")) + { + return SanitizerType.HeaderRegexSanitizer; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "HeaderStringSanitizer")) + { + return SanitizerType.HeaderStringSanitizer; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "OAuthResponseSanitizer")) + { + return SanitizerType.OAuthResponseSanitizer; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "RegexEntrySanitizer")) + { + return SanitizerType.RegexEntrySanitizer; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "RemoveHeaderSanitizer")) + { + return SanitizerType.RemoveHeaderSanitizer; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "UriRegexSanitizer")) + { + return SanitizerType.UriRegexSanitizer; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "UriStringSanitizer")) + { + return SanitizerType.UriStringSanitizer; + } + if (StringComparer.OrdinalIgnoreCase.Equals(value, "UriSubscriptionIdSanitizer")) + { + return SanitizerType.UriSubscriptionIdSanitizer; + } + throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown SanitizerType value."); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerType.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerType.cs new file mode 100644 index 0000000000..c21147e7b4 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/SanitizerType.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// + internal enum SanitizerType + { + /// BodyKeySanitizer. + BodyKeySanitizer, + /// BodyRegexSanitizer. + BodyRegexSanitizer, + /// BodyStringSanitizer. + BodyStringSanitizer, + /// GeneralRegexSanitizer. + GeneralRegexSanitizer, + /// GeneralStringSanitizer. + GeneralStringSanitizer, + /// HeaderRegexSanitizer. + HeaderRegexSanitizer, + /// HeaderStringSanitizer. + HeaderStringSanitizer, + /// OAuthResponseSanitizer. + OAuthResponseSanitizer, + /// RegexEntrySanitizer. + RegexEntrySanitizer, + /// RemoveHeaderSanitizer. + RemoveHeaderSanitizer, + /// UriRegexSanitizer. + UriRegexSanitizer, + /// UriStringSanitizer. + UriStringSanitizer, + /// UriSubscriptionIdSanitizer. + UriSubscriptionIdSanitizer + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/StoreType.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/StoreType.Serialization.cs new file mode 100644 index 0000000000..cbe5fbac28 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/StoreType.Serialization.cs @@ -0,0 +1,31 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; + +namespace Azure.Mcp.Tests.Generated.Models +{ + internal static partial class StoreTypeExtensions + { + /// The value to serialize. + public static string ToSerialString(this StoreType value) => value switch + { + StoreType.GitStore => "GitStore", + _ => throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown StoreType value.") + }; + + /// The value to deserialize. + public static StoreType ToStoreType(this string value) + { + if (StringComparer.OrdinalIgnoreCase.Equals(value, "GitStore")) + { + return StoreType.GitStore; + } + throw new ArgumentOutOfRangeException(nameof(value), value, "Unknown StoreType value."); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/StoreType.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/StoreType.cs new file mode 100644 index 0000000000..6acac5466c --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/StoreType.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// + public enum StoreType + { + /// GitStore. + GitStore + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyCertificate.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyCertificate.Serialization.cs new file mode 100644 index 0000000000..dc6bcdfebd --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyCertificate.Serialization.cs @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The TestProxyCertificate. + public partial class TestProxyCertificate : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal TestProxyCertificate() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(TestProxyCertificate)} does not support writing '{format}' format."); + } + writer.WritePropertyName("PemValue"u8); + writer.WriteStringValue(PemValue); + writer.WritePropertyName("PemKey"u8); + writer.WriteStringValue(PemKey); + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + TestProxyCertificate IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual TestProxyCertificate JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(TestProxyCertificate)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeTestProxyCertificate(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static TestProxyCertificate DeserializeTestProxyCertificate(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string pemValue = default; + string pemKey = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("PemValue"u8)) + { + pemValue = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("PemKey"u8)) + { + pemKey = prop.Value.GetString(); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new TestProxyCertificate(pemValue, pemKey, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(TestProxyCertificate)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + TestProxyCertificate IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual TestProxyCertificate PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeTestProxyCertificate(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(TestProxyCertificate)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyCertificate.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyCertificate.cs new file mode 100644 index 0000000000..2dec7b39ee --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyCertificate.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The TestProxyCertificate. + public partial class TestProxyCertificate + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + /// + /// or is null. + public TestProxyCertificate(string pemValue, string pemKey) + { + Argument.AssertNotNull(pemValue, nameof(pemValue)); + Argument.AssertNotNull(pemKey, nameof(pemKey)); + + PemValue = pemValue; + PemKey = pemKey; + } + + /// Initializes a new instance of . + /// + /// + /// Keeps track of any properties unknown to the library. + internal TestProxyCertificate(string pemValue, string pemKey, IDictionary additionalBinaryDataProperties) + { + PemValue = pemValue; + PemKey = pemKey; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the PemValue. + public string PemValue { get; } + + /// Gets the PemKey. + public string PemKey { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyStartInformation.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyStartInformation.Serialization.cs new file mode 100644 index 0000000000..4620bfdbfc --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyStartInformation.Serialization.cs @@ -0,0 +1,166 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The TestProxyStartInformation. + public partial class TestProxyStartInformation : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal TestProxyStartInformation() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(TestProxyStartInformation)} does not support writing '{format}' format."); + } + writer.WritePropertyName("x-recording-file"u8); + writer.WriteStringValue(XRecordingFile); + if (Optional.IsDefined(XRecordingAssetsFile)) + { + writer.WritePropertyName("x-recording-assets-file"u8); + writer.WriteStringValue(XRecordingAssetsFile); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + TestProxyStartInformation IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual TestProxyStartInformation JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(TestProxyStartInformation)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeTestProxyStartInformation(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static TestProxyStartInformation DeserializeTestProxyStartInformation(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string xRecordingFile = default; + string xRecordingAssetsFile = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("x-recording-file"u8)) + { + xRecordingFile = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("x-recording-assets-file"u8)) + { + xRecordingAssetsFile = prop.Value.GetString(); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new TestProxyStartInformation(xRecordingFile, xRecordingAssetsFile, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(TestProxyStartInformation)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + TestProxyStartInformation IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual TestProxyStartInformation PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeTestProxyStartInformation(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(TestProxyStartInformation)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + + /// The to serialize into . + public static implicit operator BinaryContent(TestProxyStartInformation testProxyStartInformation) + { + if (testProxyStartInformation == null) + { + return null; + } + return BinaryContent.Create(testProxyStartInformation, ModelSerializationExtensions.WireOptions); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyStartInformation.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyStartInformation.cs new file mode 100644 index 0000000000..a62fc2f32e --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TestProxyStartInformation.cs @@ -0,0 +1,47 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models; + +/// The TestProxyStartInformation. +public partial class TestProxyStartInformation +{ + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + /// is null. + public TestProxyStartInformation(string xRecordingFile) + { + Argument.AssertNotNull(xRecordingFile, nameof(xRecordingFile)); + + XRecordingFile = xRecordingFile; + } + + /// Initializes a new instance of . + /// + /// + /// Keeps track of any properties unknown to the library. + internal TestProxyStartInformation(string xRecordingFile, string xRecordingAssetsFile, IDictionary additionalBinaryDataProperties) + { + XRecordingFile = xRecordingFile; + XRecordingAssetsFile = xRecordingAssetsFile; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the XRecordingFile. + public string XRecordingFile { get; } + + /// Gets or sets the XRecordingAssetsFile. + public string XRecordingAssetsFile { get; set; } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TransportCustomizations.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TransportCustomizations.Serialization.cs new file mode 100644 index 0000000000..491ff0c35e --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TransportCustomizations.Serialization.cs @@ -0,0 +1,214 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The TransportCustomizations. + public partial class TransportCustomizations : IJsonModel + { + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(TransportCustomizations)} does not support writing '{format}' format."); + } + if (Optional.IsDefined(AllowAutoRedirect)) + { + writer.WritePropertyName("AllowAutoRedirect"u8); + writer.WriteBooleanValue(AllowAutoRedirect.Value); + } + if (Optional.IsDefined(TLSValidationCert)) + { + writer.WritePropertyName("TLSValidationCert"u8); + writer.WriteStringValue(TLSValidationCert); + } + if (Optional.IsDefined(TLSValidationCertHost)) + { + writer.WritePropertyName("TLSValidationCertHost"u8); + writer.WriteStringValue(TLSValidationCertHost); + } + if (Optional.IsCollectionDefined(Certificates)) + { + writer.WritePropertyName("Certificates"u8); + writer.WriteStartArray(); + foreach (TestProxyCertificate item in Certificates) + { + writer.WriteObjectValue(item, options); + } + writer.WriteEndArray(); + } + if (Optional.IsDefined(PlaybackResponseTime)) + { + writer.WritePropertyName("PlaybackResponseTime"u8); + writer.WriteNumberValue(PlaybackResponseTime.Value); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + TransportCustomizations IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual TransportCustomizations JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(TransportCustomizations)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeTransportCustomizations(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static TransportCustomizations DeserializeTransportCustomizations(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + bool? allowAutoRedirect = default; + string tlsValidationCert = default; + string tlsValidationCertHost = default; + IList certificates = default; + int? playbackResponseTime = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("AllowAutoRedirect"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + allowAutoRedirect = prop.Value.GetBoolean(); + continue; + } + if (prop.NameEquals("TLSValidationCert"u8)) + { + tlsValidationCert = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("TLSValidationCertHost"u8)) + { + tlsValidationCertHost = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("Certificates"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + List array = new List(); + foreach (var item in prop.Value.EnumerateArray()) + { + array.Add(TestProxyCertificate.DeserializeTestProxyCertificate(item, options)); + } + certificates = array; + continue; + } + if (prop.NameEquals("PlaybackResponseTime"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + playbackResponseTime = prop.Value.GetInt32(); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new TransportCustomizations( + allowAutoRedirect, + tlsValidationCert, + tlsValidationCertHost, + certificates ?? new ChangeTrackingList(), + playbackResponseTime, + additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(TransportCustomizations)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + TransportCustomizations IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual TransportCustomizations PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeTransportCustomizations(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(TransportCustomizations)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TransportCustomizations.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TransportCustomizations.cs new file mode 100644 index 0000000000..c2db2212ff --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/TransportCustomizations.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The TransportCustomizations. + public partial class TransportCustomizations + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + public TransportCustomizations() + { + Certificates = new ChangeTrackingList(); + } + + /// Initializes a new instance of . + /// + /// + /// + /// + /// + /// Keeps track of any properties unknown to the library. + internal TransportCustomizations(bool? allowAutoRedirect, string tlsValidationCert, string tlsValidationCertHost, IList certificates, int? playbackResponseTime, IDictionary additionalBinaryDataProperties) + { + AllowAutoRedirect = allowAutoRedirect; + TLSValidationCert = tlsValidationCert; + TLSValidationCertHost = tlsValidationCertHost; + Certificates = certificates; + PlaybackResponseTime = playbackResponseTime; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets or sets the AllowAutoRedirect. + public bool? AllowAutoRedirect { get; set; } + + /// Gets or sets the TLSValidationCert. + public string TLSValidationCert { get; set; } + + /// Gets or sets the TLSValidationCertHost. + public string TLSValidationCertHost { get; set; } + + /// Gets the Certificates. + public IList Certificates { get; } + + /// Gets or sets the PlaybackResponseTime. + public int? PlaybackResponseTime { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UnknownSanitizerAddition.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UnknownSanitizerAddition.Serialization.cs new file mode 100644 index 0000000000..e2781b2f6c --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UnknownSanitizerAddition.Serialization.cs @@ -0,0 +1,127 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + internal partial class UnknownSanitizerAddition : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal UnknownSanitizerAddition() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(SanitizerAddition)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + SanitizerAddition IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(SanitizerAddition)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeSanitizerAddition(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static UnknownSanitizerAddition DeserializeUnknownSanitizerAddition(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new UnknownSanitizerAddition(name, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(SanitizerAddition)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + SanitizerAddition IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeSanitizerAddition(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(SanitizerAddition)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UnknownSanitizerAddition.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UnknownSanitizerAddition.cs new file mode 100644 index 0000000000..976bba8a65 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UnknownSanitizerAddition.cs @@ -0,0 +1,22 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Azure.Mcp.Tests.Generated.Models +{ + internal partial class UnknownSanitizerAddition : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + internal UnknownSanitizerAddition(SanitizerType name, IDictionary additionalBinaryDataProperties) : base(name, additionalBinaryDataProperties) + { + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizer.Serialization.cs new file mode 100644 index 0000000000..07ba61637e --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizer.Serialization.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The UriRegexSanitizer. + public partial class UriRegexSanitizer : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal UriRegexSanitizer() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(UriRegexSanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + writer.WritePropertyName("Body"u8); + writer.WriteObjectValue(Body, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + UriRegexSanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (UriRegexSanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(UriRegexSanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeUriRegexSanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static UriRegexSanitizer DeserializeUriRegexSanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + UriRegexSanitizerBody body = default; + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (prop.NameEquals("Body"u8)) + { + body = UriRegexSanitizerBody.DeserializeUriRegexSanitizerBody(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new UriRegexSanitizer(name, additionalBinaryDataProperties, body); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(UriRegexSanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + UriRegexSanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (UriRegexSanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeUriRegexSanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(UriRegexSanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizer.cs new file mode 100644 index 0000000000..ec41088cd5 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizer.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The UriRegexSanitizer. + public partial class UriRegexSanitizer : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// is null. + public UriRegexSanitizer(UriRegexSanitizerBody body) : base(SanitizerType.UriRegexSanitizer) + { + Argument.AssertNotNull(body, nameof(body)); + + Body = body; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + /// + internal UriRegexSanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties, UriRegexSanitizerBody body) : base(name, additionalBinaryDataProperties) + { + Body = body; + } + + /// Gets the Body. + public UriRegexSanitizerBody Body { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizerBody.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizerBody.Serialization.cs new file mode 100644 index 0000000000..352d1f72be --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizerBody.Serialization.cs @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The UriRegexSanitizerBody. + public partial class UriRegexSanitizerBody : IJsonModel + { + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(UriRegexSanitizerBody)} does not support writing '{format}' format."); + } + if (Optional.IsDefined(Value)) + { + writer.WritePropertyName("value"u8); + writer.WriteStringValue(Value); + } + if (Optional.IsDefined(Regex)) + { + writer.WritePropertyName("regex"u8); + writer.WriteStringValue(Regex); + } + if (Optional.IsDefined(GroupForReplace)) + { + writer.WritePropertyName("groupForReplace"u8); + writer.WriteStringValue(GroupForReplace); + } + if (Optional.IsDefined(Condition)) + { + writer.WritePropertyName("condition"u8); + writer.WriteObjectValue(Condition, options); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + UriRegexSanitizerBody IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual UriRegexSanitizerBody JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(UriRegexSanitizerBody)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeUriRegexSanitizerBody(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static UriRegexSanitizerBody DeserializeUriRegexSanitizerBody(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string value = default; + string regex = default; + string groupForReplace = default; + ApplyCondition condition = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("value"u8)) + { + value = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("regex"u8)) + { + regex = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("groupForReplace"u8)) + { + groupForReplace = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("condition"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + condition = ApplyCondition.DeserializeApplyCondition(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new UriRegexSanitizerBody(value, regex, groupForReplace, condition, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(UriRegexSanitizerBody)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + UriRegexSanitizerBody IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual UriRegexSanitizerBody PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeUriRegexSanitizerBody(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(UriRegexSanitizerBody)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizerBody.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizerBody.cs new file mode 100644 index 0000000000..9b6a1b88fe --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriRegexSanitizerBody.cs @@ -0,0 +1,51 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The UriRegexSanitizerBody. + public partial class UriRegexSanitizerBody + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + public UriRegexSanitizerBody() + { + } + + /// Initializes a new instance of . + /// + /// + /// + /// + /// Keeps track of any properties unknown to the library. + internal UriRegexSanitizerBody(string value, string regex, string groupForReplace, ApplyCondition condition, IDictionary additionalBinaryDataProperties) + { + Value = value; + Regex = regex; + GroupForReplace = groupForReplace; + Condition = condition; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets or sets the Value. + public string Value { get; set; } + + /// Gets or sets the Regex. + public string Regex { get; set; } + + /// Gets or sets the GroupForReplace. + public string GroupForReplace { get; set; } + + /// Gets or sets the Condition. + public ApplyCondition Condition { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizer.Serialization.cs new file mode 100644 index 0000000000..e01ea7ea1f --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizer.Serialization.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The UriStringSanitizer. + public partial class UriStringSanitizer : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal UriStringSanitizer() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(UriStringSanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + writer.WritePropertyName("Body"u8); + writer.WriteObjectValue(Body, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + UriStringSanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (UriStringSanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(UriStringSanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeUriStringSanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static UriStringSanitizer DeserializeUriStringSanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + UriStringSanitizerBody body = default; + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (prop.NameEquals("Body"u8)) + { + body = UriStringSanitizerBody.DeserializeUriStringSanitizerBody(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new UriStringSanitizer(name, additionalBinaryDataProperties, body); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(UriStringSanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + UriStringSanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (UriStringSanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeUriStringSanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(UriStringSanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizer.cs new file mode 100644 index 0000000000..a0d50fb404 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizer.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The UriStringSanitizer. + public partial class UriStringSanitizer : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// is null. + public UriStringSanitizer(UriStringSanitizerBody body) : base(SanitizerType.UriStringSanitizer) + { + Argument.AssertNotNull(body, nameof(body)); + + Body = body; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + /// + internal UriStringSanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties, UriStringSanitizerBody body) : base(name, additionalBinaryDataProperties) + { + Body = body; + } + + /// Gets the Body. + public UriStringSanitizerBody Body { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizerBody.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizerBody.Serialization.cs new file mode 100644 index 0000000000..b53b93c5b4 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizerBody.Serialization.cs @@ -0,0 +1,170 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The UriStringSanitizerBody. + public partial class UriStringSanitizerBody : IJsonModel + { + /// Initializes a new instance of for deserialization. + internal UriStringSanitizerBody() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(UriStringSanitizerBody)} does not support writing '{format}' format."); + } + writer.WritePropertyName("target"u8); + writer.WriteStringValue(Target); + if (Optional.IsDefined(Value)) + { + writer.WritePropertyName("value"u8); + writer.WriteStringValue(Value); + } + if (Optional.IsDefined(Condition)) + { + writer.WritePropertyName("condition"u8); + writer.WriteObjectValue(Condition, options); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + UriStringSanitizerBody IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual UriStringSanitizerBody JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(UriStringSanitizerBody)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeUriStringSanitizerBody(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static UriStringSanitizerBody DeserializeUriStringSanitizerBody(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string target = default; + string value = default; + ApplyCondition condition = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("target"u8)) + { + target = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("value"u8)) + { + value = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("condition"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + condition = ApplyCondition.DeserializeApplyCondition(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new UriStringSanitizerBody(target, value, condition, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(UriStringSanitizerBody)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + UriStringSanitizerBody IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual UriStringSanitizerBody PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeUriStringSanitizerBody(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(UriStringSanitizerBody)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizerBody.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizerBody.cs new file mode 100644 index 0000000000..1c2d8d632e --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriStringSanitizerBody.cs @@ -0,0 +1,53 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The UriStringSanitizerBody. + public partial class UriStringSanitizerBody + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + /// + /// is null. + public UriStringSanitizerBody(string target) + { + Argument.AssertNotNull(target, nameof(target)); + + Target = target; + } + + /// Initializes a new instance of . + /// + /// + /// + /// Keeps track of any properties unknown to the library. + internal UriStringSanitizerBody(string target, string value, ApplyCondition condition, IDictionary additionalBinaryDataProperties) + { + Target = target; + Value = value; + Condition = condition; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets the Target. + public string Target { get; } + + /// Gets or sets the Value. + public string Value { get; set; } + + /// Gets or sets the Condition. + public ApplyCondition Condition { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizer.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizer.Serialization.cs new file mode 100644 index 0000000000..07e7e1ad4d --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizer.Serialization.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The UriSubscriptionIdSanitizer. + public partial class UriSubscriptionIdSanitizer : SanitizerAddition, IJsonModel + { + /// Initializes a new instance of for deserialization. + internal UriSubscriptionIdSanitizer() + { + } + + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected override void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(UriSubscriptionIdSanitizer)} does not support writing '{format}' format."); + } + base.JsonModelWriteCore(writer, options); + writer.WritePropertyName("Body"u8); + writer.WriteObjectValue(Body, options); + } + + /// The JSON reader. + /// The client options for reading and writing models. + UriSubscriptionIdSanitizer IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => (UriSubscriptionIdSanitizer)JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected override SanitizerAddition JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(UriSubscriptionIdSanitizer)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeUriSubscriptionIdSanitizer(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static UriSubscriptionIdSanitizer DeserializeUriSubscriptionIdSanitizer(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + SanitizerType name = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + UriSubscriptionIdSanitizerBody body = default; + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("Name"u8)) + { + name = prop.Value.GetString().ToSanitizerType(); + continue; + } + if (prop.NameEquals("Body"u8)) + { + body = UriSubscriptionIdSanitizerBody.DeserializeUriSubscriptionIdSanitizerBody(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new UriSubscriptionIdSanitizer(name, additionalBinaryDataProperties, body); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected override BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(UriSubscriptionIdSanitizer)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + UriSubscriptionIdSanitizer IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => (UriSubscriptionIdSanitizer)PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected override SanitizerAddition PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeUriSubscriptionIdSanitizer(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(UriSubscriptionIdSanitizer)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizer.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizer.cs new file mode 100644 index 0000000000..c60ae7b86d --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizer.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; +using Azure.Mcp.Tests.Generated; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The UriSubscriptionIdSanitizer. + public partial class UriSubscriptionIdSanitizer : SanitizerAddition + { + /// Initializes a new instance of . + /// + /// is null. + public UriSubscriptionIdSanitizer(UriSubscriptionIdSanitizerBody body) : base(SanitizerType.UriSubscriptionIdSanitizer) + { + Argument.AssertNotNull(body, nameof(body)); + + Body = body; + } + + /// Initializes a new instance of . + /// + /// Keeps track of any properties unknown to the library. + /// + internal UriSubscriptionIdSanitizer(SanitizerType name, IDictionary additionalBinaryDataProperties, UriSubscriptionIdSanitizerBody body) : base(name, additionalBinaryDataProperties) + { + Body = body; + } + + /// Gets the Body. + public UriSubscriptionIdSanitizerBody Body { get; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizerBody.Serialization.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizerBody.Serialization.cs new file mode 100644 index 0000000000..8a3c05a093 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizerBody.Serialization.cs @@ -0,0 +1,156 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Text.Json; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The UriSubscriptionIdSanitizerBody. + public partial class UriSubscriptionIdSanitizerBody : IJsonModel + { + /// The JSON writer. + /// The client options for reading and writing models. + void IJsonModel.Write(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + writer.WriteStartObject(); + JsonModelWriteCore(writer, options); + writer.WriteEndObject(); + } + + /// The JSON writer. + /// The client options for reading and writing models. + protected virtual void JsonModelWriteCore(Utf8JsonWriter writer, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(UriSubscriptionIdSanitizerBody)} does not support writing '{format}' format."); + } + if (Optional.IsDefined(Value)) + { + writer.WritePropertyName("value"u8); + writer.WriteStringValue(Value); + } + if (Optional.IsDefined(Condition)) + { + writer.WritePropertyName("condition"u8); + writer.WriteObjectValue(Condition, options); + } + if (options.Format != "W" && _additionalBinaryDataProperties != null) + { + foreach (var item in _additionalBinaryDataProperties) + { + writer.WritePropertyName(item.Key); +#if NET6_0_OR_GREATER + writer.WriteRawValue(item.Value); +#else + using (JsonDocument document = JsonDocument.Parse(item.Value)) + { + JsonSerializer.Serialize(writer, document.RootElement); + } +#endif + } + } + } + + /// The JSON reader. + /// The client options for reading and writing models. + UriSubscriptionIdSanitizerBody IJsonModel.Create(ref Utf8JsonReader reader, ModelReaderWriterOptions options) => JsonModelCreateCore(ref reader, options); + + /// The JSON reader. + /// The client options for reading and writing models. + protected virtual UriSubscriptionIdSanitizerBody JsonModelCreateCore(ref Utf8JsonReader reader, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + if (format != "J") + { + throw new FormatException($"The model {nameof(UriSubscriptionIdSanitizerBody)} does not support reading '{format}' format."); + } + using JsonDocument document = JsonDocument.ParseValue(ref reader); + return DeserializeUriSubscriptionIdSanitizerBody(document.RootElement, options); + } + + /// The JSON element to deserialize. + /// The client options for reading and writing models. + internal static UriSubscriptionIdSanitizerBody DeserializeUriSubscriptionIdSanitizerBody(JsonElement element, ModelReaderWriterOptions options) + { + if (element.ValueKind == JsonValueKind.Null) + { + return null; + } + string value = default; + ApplyCondition condition = default; + IDictionary additionalBinaryDataProperties = new ChangeTrackingDictionary(); + foreach (var prop in element.EnumerateObject()) + { + if (prop.NameEquals("value"u8)) + { + value = prop.Value.GetString(); + continue; + } + if (prop.NameEquals("condition"u8)) + { + if (prop.Value.ValueKind == JsonValueKind.Null) + { + continue; + } + condition = ApplyCondition.DeserializeApplyCondition(prop.Value, options); + continue; + } + if (options.Format != "W") + { + additionalBinaryDataProperties.Add(prop.Name, BinaryData.FromString(prop.Value.GetRawText())); + } + } + return new UriSubscriptionIdSanitizerBody(value, condition, additionalBinaryDataProperties); + } + + /// The client options for reading and writing models. + BinaryData IPersistableModel.Write(ModelReaderWriterOptions options) => PersistableModelWriteCore(options); + + /// The client options for reading and writing models. + protected virtual BinaryData PersistableModelWriteCore(ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + return ModelReaderWriter.Write(this, options, MicrosoftClientModelTestFrameworkContext.Default); + default: + throw new FormatException($"The model {nameof(UriSubscriptionIdSanitizerBody)} does not support writing '{options.Format}' format."); + } + } + + /// The data to parse. + /// The client options for reading and writing models. + UriSubscriptionIdSanitizerBody IPersistableModel.Create(BinaryData data, ModelReaderWriterOptions options) => PersistableModelCreateCore(data, options); + + /// The data to parse. + /// The client options for reading and writing models. + protected virtual UriSubscriptionIdSanitizerBody PersistableModelCreateCore(BinaryData data, ModelReaderWriterOptions options) + { + string format = options.Format == "W" ? ((IPersistableModel)this).GetFormatFromOptions(options) : options.Format; + switch (format) + { + case "J": + using (JsonDocument document = JsonDocument.Parse(data)) + { + return DeserializeUriSubscriptionIdSanitizerBody(document.RootElement, options); + } + default: + throw new FormatException($"The model {nameof(UriSubscriptionIdSanitizerBody)} does not support reading '{options.Format}' format."); + } + } + + /// The client options for reading and writing models. + string IPersistableModel.GetFormatFromOptions(ModelReaderWriterOptions options) => "J"; + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizerBody.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizerBody.cs new file mode 100644 index 0000000000..b56d3ae97d --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/Models/UriSubscriptionIdSanitizerBody.cs @@ -0,0 +1,41 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.Collections.Generic; + +namespace Azure.Mcp.Tests.Generated.Models +{ + /// The UriSubscriptionIdSanitizerBody. + public partial class UriSubscriptionIdSanitizerBody + { + /// Keeps track of any properties unknown to the library. + private protected readonly IDictionary _additionalBinaryDataProperties; + + /// Initializes a new instance of . + public UriSubscriptionIdSanitizerBody() + { + } + + /// Initializes a new instance of . + /// + /// + /// Keeps track of any properties unknown to the library. + internal UriSubscriptionIdSanitizerBody(string value, ApplyCondition condition, IDictionary additionalBinaryDataProperties) + { + Value = value; + Condition = condition; + _additionalBinaryDataProperties = additionalBinaryDataProperties; + } + + /// Gets or sets the Value. + public string Value { get; set; } + + /// Gets or sets the Condition. + public ApplyCondition Condition { get; set; } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/README.md b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/README.md new file mode 100644 index 0000000000..8dc00fb3a7 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/README.md @@ -0,0 +1,31 @@ +# Regenerating this code + +- Add this file to the repo under `eng/emitter-package.json` + ```json + { + "main": "dist/src/index.js", + "dependencies": { + "@typespec/http-client-csharp": "latest" + }, + "devDependencies": { + "@azure-tools/typespec-autorest": "0.60.0", + "@azure-tools/typespec-azure-core": "0.60.0", + "@azure-tools/typespec-azure-resource-manager": "0.60.0", + "@azure-tools/typespec-azure-rulesets": "0.60.0", + "@azure-tools/typespec-client-generator-core": "0.60.0", + "@azure-tools/typespec-liftr-base": "0.8.0", + "@typespec/compiler": "1.4.0", + "@typespec/events": "0.74.0", + "@typespec/http": "1.4.0", + "@typespec/openapi": "1.4.0", + "@typespec/rest": "0.74.0", + "@typespec/sse": "0.74.0", + "@typespec/streams": "0.74.0", + "@typespec/versioning": "0.74.0", + "@typespec/xml": "0.74.0" + } + } + ``` +- Then install `tsp-client` to your NODE installation globally: `npm install -g @azure-tools/typespec-client-generator-cli` +- `tsp-client update` from within the directory containing the `tsp-location.yaml` file. +- This will regenerate the C# client code that resides in this directory. diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyAdminClient.RestClient.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyAdminClient.RestClient.cs new file mode 100644 index 0000000000..4a53599c7e --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyAdminClient.RestClient.cs @@ -0,0 +1,93 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System.ClientModel; +using System.ClientModel.Primitives; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated +{ + /// + public partial class TestProxyAdminClient + { + private static PipelineMessageClassifier _pipelineMessageClassifier200; + + private static PipelineMessageClassifier PipelineMessageClassifier200 => _pipelineMessageClassifier200 = PipelineMessageClassifier.Create(stackalloc ushort[] { 200 }); + + internal PipelineMessage CreateSetMatcherRequest(string matcherType, BinaryContent content, RequestOptions options) + { + ClientUriBuilder uri = new ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/Admin/SetMatcher", false); + PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "POST", PipelineMessageClassifier200); + PipelineRequest request = message.Request; + request.Headers.Set("x-abstraction-identifier", matcherType); + if ("application/json" != null) + { + request.Headers.Set("Content-Type", "application/json"); + } + request.Headers.Set("Accept", "application/json"); + request.Content = content; + message.Apply(options); + return message; + } + + internal PipelineMessage CreateAddSanitizersRequest(BinaryContent content, string recordingId, RequestOptions options) + { + ClientUriBuilder uri = new ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/Admin/AddSanitizers", false); + PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "POST", PipelineMessageClassifier200); + PipelineRequest request = message.Request; + if (recordingId != null) + { + request.Headers.Set("x-recording-id", recordingId); + } + request.Headers.Set("Content-Type", "application/json"); + request.Headers.Set("Accept", "application/json"); + request.Content = content; + message.Apply(options); + return message; + } + + internal PipelineMessage CreateRemoveSanitizersRequest(BinaryContent content, string recordingId, RequestOptions options) + { + ClientUriBuilder uri = new ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/Admin/RemoveSanitizers", false); + PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "POST", PipelineMessageClassifier200); + PipelineRequest request = message.Request; + if (recordingId != null) + { + request.Headers.Set("x-recording-id", recordingId); + } + request.Headers.Set("Content-Type", "application/json"); + request.Headers.Set("Accept", "application/json"); + request.Content = content; + message.Apply(options); + return message; + } + + internal PipelineMessage CreateSetRecordingOptionsRequest(BinaryContent content, string recordingId, RequestOptions options) + { + ClientUriBuilder uri = new ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/Admin/SetRecordingOptions", false); + PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "POST", PipelineMessageClassifier200); + PipelineRequest request = message.Request; + if (recordingId != null) + { + request.Headers.Set("x-recording-id", recordingId); + } + request.Headers.Set("Content-Type", "application/json"); + request.Headers.Set("Accept", "application/json"); + request.Content = content; + message.Apply(options); + return message; + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyAdminClient.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyAdminClient.cs new file mode 100644 index 0000000000..94a9664069 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyAdminClient.cs @@ -0,0 +1,345 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Azure.Mcp.Tests.Generated.Internal; +using Azure.Mcp.Tests.Generated.Models; + +namespace Azure.Mcp.Tests.Generated +{ + /// The TestProxyAdminClient sub-client. + public partial class TestProxyAdminClient + { + private readonly Uri _endpoint; + + /// Initializes a new instance of TestProxyAdminClient for mocking. + protected TestProxyAdminClient() + { + } + + /// Initializes a new instance of TestProxyAdminClient. + /// The HTTP pipeline for sending and receiving REST requests and responses. + /// Service endpoint. + internal TestProxyAdminClient(ClientPipeline pipeline, Uri endpoint) + { + _endpoint = endpoint; + Pipeline = pipeline; + } + + /// The HTTP pipeline for sending and receiving REST requests and responses. + public ClientPipeline Pipeline { get; } + + /// + /// [Protocol Method] Set the matcher for the test proxy. If a recording ID is provided in the header, + /// the matcher will be set only for that session. Otherwise, it will be set globally. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The type of matcher to set. + /// The content to send as the body of the request. + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual ClientResult SetMatcher(string matcherType, BinaryContent content = null, RequestOptions options = null) + { + using PipelineMessage message = CreateSetMatcherRequest(matcherType, content, options); + return ClientResult.FromResponse(Pipeline.ProcessMessage(message, options)); + } + + /// + /// [Protocol Method] Set the matcher for the test proxy. If a recording ID is provided in the header, + /// the matcher will be set only for that session. Otherwise, it will be set globally. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The type of matcher to set. + /// The content to send as the body of the request. + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual async Task SetMatcherAsync(string matcherType, BinaryContent content = null, RequestOptions options = null) + { + using PipelineMessage message = CreateSetMatcherRequest(matcherType, content, options); + return ClientResult.FromResponse(await Pipeline.ProcessMessageAsync(message, options).ConfigureAwait(false)); + } + + /// + /// Set the matcher for the test proxy. If a recording ID is provided in the header, + /// the matcher will be set only for that session. Otherwise, it will be set globally. + /// + /// The type of matcher to set. + /// The matcher configuration. Only required if matcherType is CustomDefaultMatcher. + /// The cancellation token that can be used to cancel the operation. + /// Service returned a non-success status code. + public virtual ClientResult SetMatcher(MatcherType matcherType, CustomDefaultMatcher matcher = default, CancellationToken cancellationToken = default) + { + return SetMatcher(matcherType.ToSerialString(), matcher, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null); + } + + /// + /// Set the matcher for the test proxy. If a recording ID is provided in the header, + /// the matcher will be set only for that session. Otherwise, it will be set globally. + /// + /// The type of matcher to set. + /// The matcher configuration. Only required if matcherType is CustomDefaultMatcher. + /// The cancellation token that can be used to cancel the operation. + /// Service returned a non-success status code. + public virtual async Task SetMatcherAsync(MatcherType matcherType, CustomDefaultMatcher matcher = default, CancellationToken cancellationToken = default) + { + return await SetMatcherAsync(matcherType.ToSerialString(), matcher, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null).ConfigureAwait(false); + } + + /// + /// [Protocol Method] Add sanitizers to the test proxy. If a recording ID is provided in the header, + /// the sanitizers will be added only for that session. Otherwise, they will be added globally. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The content to send as the body of the request. + /// + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual ClientResult AddSanitizers(BinaryContent content, string recordingId = default, RequestOptions options = null) + { + Argument.AssertNotNull(content, nameof(content)); + + using PipelineMessage message = CreateAddSanitizersRequest(content, recordingId, options); + return ClientResult.FromResponse(Pipeline.ProcessMessage(message, options)); + } + + /// + /// [Protocol Method] Add sanitizers to the test proxy. If a recording ID is provided in the header, + /// the sanitizers will be added only for that session. Otherwise, they will be added globally. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The content to send as the body of the request. + /// + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual async Task AddSanitizersAsync(BinaryContent content, string recordingId = default, RequestOptions options = null) + { + Argument.AssertNotNull(content, nameof(content)); + + using PipelineMessage message = CreateAddSanitizersRequest(content, recordingId, options); + return ClientResult.FromResponse(await Pipeline.ProcessMessageAsync(message, options).ConfigureAwait(false)); + } + + /// + /// Add sanitizers to the test proxy. If a recording ID is provided in the header, + /// the sanitizers will be added only for that session. Otherwise, they will be added globally. + /// + /// + /// + /// The cancellation token that can be used to cancel the operation. + /// is null. + /// Service returned a non-success status code. + public virtual ClientResult AddSanitizers(IEnumerable sanitizers, string recordingId = default, CancellationToken cancellationToken = default) + { + Argument.AssertNotNull(sanitizers, nameof(sanitizers)); + + using BinaryContent content = BinaryContentHelper.FromEnumerable(sanitizers); + return AddSanitizers(content, recordingId, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null); + } + + /// + /// Add sanitizers to the test proxy. If a recording ID is provided in the header, + /// the sanitizers will be added only for that session. Otherwise, they will be added globally. + /// + /// + /// + /// The cancellation token that can be used to cancel the operation. + /// is null. + /// Service returned a non-success status code. + public virtual async Task AddSanitizersAsync(IEnumerable sanitizers, string recordingId = default, CancellationToken cancellationToken = default) + { + Argument.AssertNotNull(sanitizers, nameof(sanitizers)); + + using BinaryContent content = BinaryContentHelper.FromEnumerable(sanitizers); + return await AddSanitizersAsync(content, recordingId, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null).ConfigureAwait(false); + } + + /// + /// [Protocol Method] Remove sanitizers from the test proxy. If a recording ID is provided in the header, + /// the sanitizers will be removed only for that session. Otherwise, they will be removed globally. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The content to send as the body of the request. + /// + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual ClientResult RemoveSanitizers(BinaryContent content, string recordingId = default, RequestOptions options = null) + { + Argument.AssertNotNull(content, nameof(content)); + + using PipelineMessage message = CreateRemoveSanitizersRequest(content, recordingId, options); + return ClientResult.FromResponse(Pipeline.ProcessMessage(message, options)); + } + + /// + /// [Protocol Method] Remove sanitizers from the test proxy. If a recording ID is provided in the header, + /// the sanitizers will be removed only for that session. Otherwise, they will be removed globally. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The content to send as the body of the request. + /// + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual async Task RemoveSanitizersAsync(BinaryContent content, string recordingId = default, RequestOptions options = null) + { + Argument.AssertNotNull(content, nameof(content)); + + using PipelineMessage message = CreateRemoveSanitizersRequest(content, recordingId, options); + return ClientResult.FromResponse(await Pipeline.ProcessMessageAsync(message, options).ConfigureAwait(false)); + } + + /// + /// Remove sanitizers from the test proxy. If a recording ID is provided in the header, + /// the sanitizers will be removed only for that session. Otherwise, they will be removed globally. + /// + /// + /// + /// The cancellation token that can be used to cancel the operation. + /// is null. + /// Service returned a non-success status code. + public virtual ClientResult RemoveSanitizers(SanitizerList sanitizers, string recordingId = default, CancellationToken cancellationToken = default) + { + Argument.AssertNotNull(sanitizers, nameof(sanitizers)); + + ClientResult result = RemoveSanitizers(sanitizers, recordingId, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null); + return ClientResult.FromValue((RemovedSanitizers)result, result.GetRawResponse()); + } + + /// + /// Remove sanitizers from the test proxy. If a recording ID is provided in the header, + /// the sanitizers will be removed only for that session. Otherwise, they will be removed globally. + /// + /// + /// + /// The cancellation token that can be used to cancel the operation. + /// is null. + /// Service returned a non-success status code. + public virtual async Task> RemoveSanitizersAsync(SanitizerList sanitizers, string recordingId = default, CancellationToken cancellationToken = default) + { + Argument.AssertNotNull(sanitizers, nameof(sanitizers)); + + ClientResult result = await RemoveSanitizersAsync(sanitizers, recordingId, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null).ConfigureAwait(false); + return ClientResult.FromValue((RemovedSanitizers)result, result.GetRawResponse()); + } + + /// + /// [Protocol Method] Set recording options for the test proxy. If a recording ID is provided in the header, + /// the options will be set only for that session. Otherwise, they will be set globally. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The content to send as the body of the request. + /// + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual ClientResult SetRecordingOptions(BinaryContent content, string recordingId = default, RequestOptions options = null) + { + Argument.AssertNotNull(content, nameof(content)); + + using PipelineMessage message = CreateSetRecordingOptionsRequest(content, recordingId, options); + return ClientResult.FromResponse(Pipeline.ProcessMessage(message, options)); + } + + /// + /// [Protocol Method] Set recording options for the test proxy. If a recording ID is provided in the header, + /// the options will be set only for that session. Otherwise, they will be set globally. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The content to send as the body of the request. + /// + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual async Task SetRecordingOptionsAsync(BinaryContent content, string recordingId = default, RequestOptions options = null) + { + Argument.AssertNotNull(content, nameof(content)); + + using PipelineMessage message = CreateSetRecordingOptionsRequest(content, recordingId, options); + return ClientResult.FromResponse(await Pipeline.ProcessMessageAsync(message, options).ConfigureAwait(false)); + } + + /// + /// Set recording options for the test proxy. If a recording ID is provided in the header, + /// the options will be set only for that session. Otherwise, they will be set globally. + /// + /// + /// + /// The cancellation token that can be used to cancel the operation. + /// is null. + /// Service returned a non-success status code. + public virtual ClientResult SetRecordingOptions(RecordingOptions body, string recordingId = default, CancellationToken cancellationToken = default) + { + Argument.AssertNotNull(body, nameof(body)); + + return SetRecordingOptions(body, recordingId, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null); + } + + /// + /// Set recording options for the test proxy. If a recording ID is provided in the header, + /// the options will be set only for that session. Otherwise, they will be set globally. + /// + /// + /// + /// The cancellation token that can be used to cancel the operation. + /// is null. + /// Service returned a non-success status code. + public virtual async Task SetRecordingOptionsAsync(RecordingOptions body, string recordingId = default, CancellationToken cancellationToken = default) + { + Argument.AssertNotNull(body, nameof(body)); + + return await SetRecordingOptionsAsync(body, recordingId, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null).ConfigureAwait(false); + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyClient.RestClient.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyClient.RestClient.cs new file mode 100644 index 0000000000..bb3c317573 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyClient.RestClient.cs @@ -0,0 +1,85 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System.ClientModel; +using System.ClientModel.Primitives; +using Azure.Mcp.Tests.Generated.Internal; + +namespace Azure.Mcp.Tests.Generated +{ + /// + public partial class TestProxyClient + { + private static PipelineMessageClassifier _pipelineMessageClassifier200; + + private static PipelineMessageClassifier PipelineMessageClassifier200 => _pipelineMessageClassifier200 = PipelineMessageClassifier.Create(stackalloc ushort[] { 200 }); + + internal PipelineMessage CreateStartRecordRequest(BinaryContent content, RequestOptions options) + { + ClientUriBuilder uri = new ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/Record/Start", false); + PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "POST", PipelineMessageClassifier200); + PipelineRequest request = message.Request; + request.Headers.Set("Content-Type", "application/json"); + request.Headers.Set("Accept", "application/json"); + request.Content = content; + message.Apply(options); + return message; + } + + internal PipelineMessage CreateStopRecordRequest(string recordingId, BinaryContent content, string recordingSkip, RequestOptions options) + { + ClientUriBuilder uri = new ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/Record/Stop", false); + PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "POST", PipelineMessageClassifier200); + PipelineRequest request = message.Request; + request.Headers.Set("x-recording-id", recordingId); + if (recordingSkip != null) + { + request.Headers.Set("x-recording-skip", recordingSkip); + } + request.Headers.Set("Content-Type", "application/json"); + request.Headers.Set("Accept", "application/json"); + request.Content = content; + message.Apply(options); + return message; + } + + internal PipelineMessage CreateStartPlaybackRequest(BinaryContent content, string recordingId, RequestOptions options) + { + ClientUriBuilder uri = new ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/Playback/Start", false); + PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "POST", PipelineMessageClassifier200); + PipelineRequest request = message.Request; + if (recordingId != null) + { + request.Headers.Set("x-recording-id", recordingId); + } + request.Headers.Set("Content-Type", "application/json"); + request.Headers.Set("Accept", "application/json"); + request.Content = content; + message.Apply(options); + return message; + } + + internal PipelineMessage CreateStopPlaybackRequest(string recordingId, RequestOptions options) + { + ClientUriBuilder uri = new ClientUriBuilder(); + uri.Reset(_endpoint); + uri.AppendPath("/Playback/Stop", false); + PipelineMessage message = Pipeline.CreateMessage(uri.ToUri(), "POST", PipelineMessageClassifier200); + PipelineRequest request = message.Request; + request.Headers.Set("x-recording-id", recordingId); + request.Headers.Set("Accept", "application/json"); + message.Apply(options); + return message; + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyClient.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyClient.cs new file mode 100644 index 0000000000..f24cf4334a --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyClient.cs @@ -0,0 +1,345 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System; +using System.ClientModel; +using System.ClientModel.Primitives; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Azure.Mcp.Tests.Generated.Internal; +using Azure.Mcp.Tests.Generated.Models; + +namespace Azure.Mcp.Tests.Generated +{ + /// The TestProxyClient. + public partial class TestProxyClient + { + private readonly Uri _endpoint; + private TestProxyAdminClient _cachedTestProxyAdminClient; + + /// Initializes a new instance of TestProxyClient. + public TestProxyClient() : this(new Uri("https://localhost:5001/"), new TestProxyClientOptions()) + { + } + + /// Initializes a new instance of TestProxyClient. + /// Service endpoint. + /// The options for configuring the client. + /// is null. + public TestProxyClient(Uri endpoint, TestProxyClientOptions options) + { + Argument.AssertNotNull(endpoint, nameof(endpoint)); + + options ??= new TestProxyClientOptions(); + + _endpoint = endpoint; + Pipeline = ClientPipeline.Create(options, Array.Empty(), Array.Empty(), Array.Empty()); + } + + /// The HTTP pipeline for sending and receiving REST requests and responses. + public ClientPipeline Pipeline { get; } + + /// + /// [Protocol Method] Start recording for a test. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The content to send as the body of the request. + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual ClientResult StartRecord(BinaryContent content, RequestOptions options = null) + { + Argument.AssertNotNull(content, nameof(content)); + + using PipelineMessage message = CreateStartRecordRequest(content, options); + return ClientResult.FromResponse(Pipeline.ProcessMessage(message, options)); + } + + /// + /// [Protocol Method] Start recording for a test. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The content to send as the body of the request. + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual async Task StartRecordAsync(BinaryContent content, RequestOptions options = null) + { + Argument.AssertNotNull(content, nameof(content)); + + using PipelineMessage message = CreateStartRecordRequest(content, options); + return ClientResult.FromResponse(await Pipeline.ProcessMessageAsync(message, options).ConfigureAwait(false)); + } + + /// Start recording for a test. + /// File location of the recording. + /// The cancellation token that can be used to cancel the operation. + /// is null. + /// Service returned a non-success status code. + public virtual ClientResult StartRecord(TestProxyStartInformation body, CancellationToken cancellationToken = default) + { + Argument.AssertNotNull(body, nameof(body)); + + return StartRecord(body, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null); + } + + /// Start recording for a test. + /// File location of the recording. + /// The cancellation token that can be used to cancel the operation. + /// is null. + /// Service returned a non-success status code. + public virtual async Task StartRecordAsync(TestProxyStartInformation body, CancellationToken cancellationToken = default) + { + Argument.AssertNotNull(body, nameof(body)); + + return await StartRecordAsync(body, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null).ConfigureAwait(false); + } + + /// + /// [Protocol Method] Stop recording for a test. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The recording ID. + /// The content to send as the body of the request. + /// Optional header that can be set to request-response to skip recording this session. + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// or is null. + /// is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual ClientResult StopRecord(string recordingId, BinaryContent content, string recordingSkip = default, RequestOptions options = null) + { + Argument.AssertNotNullOrEmpty(recordingId, nameof(recordingId)); + Argument.AssertNotNull(content, nameof(content)); + + using PipelineMessage message = CreateStopRecordRequest(recordingId, content, recordingSkip, options); + return ClientResult.FromResponse(Pipeline.ProcessMessage(message, options)); + } + + /// + /// [Protocol Method] Stop recording for a test. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The recording ID. + /// The content to send as the body of the request. + /// Optional header that can be set to request-response to skip recording this session. + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// or is null. + /// is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual async Task StopRecordAsync(string recordingId, BinaryContent content, string recordingSkip = default, RequestOptions options = null) + { + Argument.AssertNotNullOrEmpty(recordingId, nameof(recordingId)); + Argument.AssertNotNull(content, nameof(content)); + + using PipelineMessage message = CreateStopRecordRequest(recordingId, content, recordingSkip, options); + return ClientResult.FromResponse(await Pipeline.ProcessMessageAsync(message, options).ConfigureAwait(false)); + } + + /// Stop recording for a test. + /// The recording ID. + /// A set of variables for the recording session. + /// Optional header that can be set to request-response to skip recording this session. + /// The cancellation token that can be used to cancel the operation. + /// or is null. + /// is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + public virtual ClientResult StopRecord(string recordingId, IDictionary variables, string recordingSkip = default, CancellationToken cancellationToken = default) + { + Argument.AssertNotNullOrEmpty(recordingId, nameof(recordingId)); + Argument.AssertNotNull(variables, nameof(variables)); + + using BinaryContent content = BinaryContentHelper.FromDictionary(variables); + return StopRecord(recordingId, content, recordingSkip, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null); + } + + /// Stop recording for a test. + /// The recording ID. + /// A set of variables for the recording session. + /// Optional header that can be set to request-response to skip recording this session. + /// The cancellation token that can be used to cancel the operation. + /// or is null. + /// is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + public virtual async Task StopRecordAsync(string recordingId, IDictionary variables, string recordingSkip = default, CancellationToken cancellationToken = default) + { + Argument.AssertNotNullOrEmpty(recordingId, nameof(recordingId)); + Argument.AssertNotNull(variables, nameof(variables)); + + using BinaryContent content = BinaryContentHelper.FromDictionary(variables); + return await StopRecordAsync(recordingId, content, recordingSkip, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null).ConfigureAwait(false); + } + + /// + /// [Protocol Method] Start playback for a test recording. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The content to send as the body of the request. + /// The recording ID. If provided, the server will duplicate an existing playback session and return the new session's recordingId. + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual ClientResult StartPlayback(BinaryContent content, string recordingId = default, RequestOptions options = null) + { + Argument.AssertNotNull(content, nameof(content)); + + using PipelineMessage message = CreateStartPlaybackRequest(content, recordingId, options); + return ClientResult.FromResponse(Pipeline.ProcessMessage(message, options)); + } + + /// + /// [Protocol Method] Start playback for a test recording. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The content to send as the body of the request. + /// The recording ID. If provided, the server will duplicate an existing playback session and return the new session's recordingId. + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual async Task StartPlaybackAsync(BinaryContent content, string recordingId = default, RequestOptions options = null) + { + Argument.AssertNotNull(content, nameof(content)); + + using PipelineMessage message = CreateStartPlaybackRequest(content, recordingId, options); + return ClientResult.FromResponse(await Pipeline.ProcessMessageAsync(message, options).ConfigureAwait(false)); + } + + /// Start playback for a test recording. + /// File location of the recording. + /// The recording ID. If provided, the server will duplicate an existing playback session and return the new session's recordingId. + /// The cancellation token that can be used to cancel the operation. + /// is null. + /// Service returned a non-success status code. + public virtual ClientResult> StartPlayback(TestProxyStartInformation body, string recordingId = default, CancellationToken cancellationToken = default) + { + Argument.AssertNotNull(body, nameof(body)); + + ClientResult result = StartPlayback(body, recordingId, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null); + return ClientResult.FromValue(result.GetRawResponse().Content.ToObjectFromJson>(), result.GetRawResponse()); + } + + /// Start playback for a test recording. + /// File location of the recording. + /// The recording ID. If provided, the server will duplicate an existing playback session and return the new session's recordingId. + /// The cancellation token that can be used to cancel the operation. + /// is null. + /// Service returned a non-success status code. + public virtual async Task>> StartPlaybackAsync(TestProxyStartInformation body, string recordingId = default, CancellationToken cancellationToken = default) + { + Argument.AssertNotNull(body, nameof(body)); + + ClientResult result = await StartPlaybackAsync(body, recordingId, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null).ConfigureAwait(false); + return ClientResult.FromValue(result.GetRawResponse().Content.ToObjectFromJson>(), result.GetRawResponse()); + } + + /// + /// [Protocol Method] Stop playback for a test recording. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The recording ID. + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual ClientResult StopPlayback(string recordingId, RequestOptions options) + { + Argument.AssertNotNullOrEmpty(recordingId, nameof(recordingId)); + + using PipelineMessage message = CreateStopPlaybackRequest(recordingId, options); + return ClientResult.FromResponse(Pipeline.ProcessMessage(message, options)); + } + + /// + /// [Protocol Method] Stop playback for a test recording. + /// + /// + /// This protocol method allows explicit creation of the request and processing of the response for advanced scenarios. + /// + /// + /// + /// The recording ID. + /// The request options, which can override default behaviors of the client pipeline on a per-call basis. + /// is null. + /// is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + /// The response returned from the service. + public virtual async Task StopPlaybackAsync(string recordingId, RequestOptions options) + { + Argument.AssertNotNullOrEmpty(recordingId, nameof(recordingId)); + + using PipelineMessage message = CreateStopPlaybackRequest(recordingId, options); + return ClientResult.FromResponse(await Pipeline.ProcessMessageAsync(message, options).ConfigureAwait(false)); + } + + /// Stop playback for a test recording. + /// The recording ID. + /// The cancellation token that can be used to cancel the operation. + /// is null. + /// is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + public virtual ClientResult StopPlayback(string recordingId, CancellationToken cancellationToken = default) + { + Argument.AssertNotNullOrEmpty(recordingId, nameof(recordingId)); + + return StopPlayback(recordingId, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null); + } + + /// Stop playback for a test recording. + /// The recording ID. + /// The cancellation token that can be used to cancel the operation. + /// is null. + /// is an empty string, and was expected to be non-empty. + /// Service returned a non-success status code. + public virtual async Task StopPlaybackAsync(string recordingId, CancellationToken cancellationToken = default) + { + Argument.AssertNotNullOrEmpty(recordingId, nameof(recordingId)); + + return await StopPlaybackAsync(recordingId, cancellationToken.CanBeCanceled ? new RequestOptions { CancellationToken = cancellationToken } : null).ConfigureAwait(false); + } + + /// Initializes a new instance of TestProxyAdminClient. + public virtual TestProxyAdminClient GetTestProxyAdminClient() + { + return Volatile.Read(ref _cachedTestProxyAdminClient) ?? Interlocked.CompareExchange(ref _cachedTestProxyAdminClient, new TestProxyAdminClient(Pipeline, _endpoint), null) ?? _cachedTestProxyAdminClient; + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyClientOptions.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyClientOptions.cs new file mode 100644 index 0000000000..98e58d3aec --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Generated/TestProxyClientOptions.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// + +#nullable disable + +using System.ClientModel.Primitives; + +namespace Azure.Mcp.Tests.Generated +{ + /// Client options for . + public partial class TestProxyClientOptions : ClientPipelineOptions + { + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/ClearEnvironmentVariablesBeforeTestAttribute.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/ClearEnvironmentVariablesBeforeTestAttribute.cs new file mode 100644 index 0000000000..fea9b0ab2d --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/ClearEnvironmentVariablesBeforeTestAttribute.cs @@ -0,0 +1,42 @@ +using System.Reflection; +using Xunit.v3; + +namespace Azure.Mcp.Tests.Helpers +{ + /// + /// Xunit attribute to clear known environment variables before each test is run. + /// Live tests should not use this attribute, as they may need environment variables to configure authentication and proxy. + /// + public class ClearEnvironmentVariablesBeforeTestAttribute : BeforeAfterTestAttribute + { + // These are all the known environment variables that our server may use. + // Proper test initialization should clear all of these, then set only the ones needed for the test. + private static readonly List _variablesToClear = [ + "ALL_PROXY", + "ALLOW_INSECURE_EXTERNAL_BINDING", + "APPLICATIONINSIGHTS_CONNECTION_STRING", + "ASPNETCORE_URLS", + "AZURE_CLIENT_ID", + "AZURE_CREDENTIALS", + "AZURE_MCP_AUTHENTICATION_RECORD", + "AZURE_MCP_BROWSER_AUTH_TIMEOUT_SECONDS", + "AZURE_MCP_CLIENT_ID", + "AZURE_MCP_COLLECT_TELEMETRY", + "AZURE_MCP_ENABLE_OTLP_EXPORTER", + "AZURE_MCP_ONLY_USE_BROKER_CREDENTIAL", + "AZURE_SUBSCRIPTION_ID", + "AZURE_TOKEN_CREDENTIALS", + "HTTP_PROXY", + "HTTPS_PROXY", + "NO_PROXY", + ]; + + public override void Before(MethodInfo methodUnderTest, IXunitTest test) + { + foreach (var envVar in _variablesToClear) + { + Environment.SetEnvironmentVariable(envVar, null); + } + } + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/TestEnvironment.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/TestEnvironment.cs new file mode 100644 index 0000000000..c2007df92f --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/TestEnvironment.cs @@ -0,0 +1,19 @@ +namespace Azure.Mcp.Tests.Helpers +{ + + /// + /// Execution mode for tests integrating with the test proxy. + /// + public enum TestMode + { + Live, + Record, + Playback + } + + public static class TestEnvironment + { + public static bool IsRunningInCi => + string.Equals(Environment.GetEnvironmentVariable("CI"), "true", StringComparison.OrdinalIgnoreCase) || !string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable("TF_BUILD")); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/TestHttpClientFactoryProvider.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/TestHttpClientFactoryProvider.cs new file mode 100644 index 0000000000..a63231b93a --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Helpers/TestHttpClientFactoryProvider.cs @@ -0,0 +1,37 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Services.Http; +using Azure.Mcp.Tests.Client.Helpers; +using Microsoft.Extensions.DependencyInjection; + +namespace Azure.Mcp.Tests.Helpers; + +/// +/// Provides a test helper for creating a pre-configured with HTTP client services. +/// This is intended for use in unit and integration tests that require HTTP client dependencies. +/// +public static class TestHttpClientFactoryProvider +{ + /// + /// Creates a new instance with HTTP client services configured for testing. + /// + /// + /// A containing the configured HTTP client services for use in tests. + /// + public static ServiceProvider Create(TestProxyFixture? fixture = null) + { + Func? recordingProxyResolver = fixture == null ? null : fixture.GetProxyUri; + + var services = new ServiceCollection(); + services.AddOptions(); + services.Configure(_ => { }); + services.Configure(_ => { }); + services.AddHttpClient(); + + services.ConfigureDefaultHttpClient(recordingProxyResolver); + return services.BuildServiceProvider(); + } +} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/TestEnvVar.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/TestEnvVar.cs deleted file mode 100644 index 66d976cd5b..0000000000 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/TestEnvVar.cs +++ /dev/null @@ -1,108 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -namespace Azure.Mcp.Tests; - -public class TestEnvVar : DisposableConfig -{ - private static SemaphoreSlim _lock = new(1, 1); - public TestEnvVar(string name, string value) : base(name, value, _lock) { } - public TestEnvVar(Dictionary values) : base(values, _lock) { } - - internal override void SetValue(string name, string value) - { - if (string.IsNullOrEmpty(name)) - { - throw new ArgumentNullException(nameof(name)); - } - if (string.IsNullOrEmpty(value)) - { - throw new ArgumentNullException(nameof(value)); - } - - _originalValues[name] = Environment.GetEnvironmentVariable(name); - - CleanExistingEnvironmentVariables(); - - Environment.SetEnvironmentVariable(name, value); - } - - internal override void SetValues(Dictionary values) - { - foreach (var kvp in values) - { - _originalValues[kvp.Key] = Environment.GetEnvironmentVariable(kvp.Key); - } - - CleanExistingEnvironmentVariables(); - - foreach (var kvp in values) - { - Environment.SetEnvironmentVariable(kvp.Key, kvp.Value); - } - } - - internal override void InitValues() - { } - - // clear the existing values so that the test needs only set up the values relevant to it. - private void CleanExistingEnvironmentVariables() - { - foreach (var kvp in _originalValues) - { - Environment.SetEnvironmentVariable(kvp.Key, null); - } - } - - internal override void Cleanup() - { - foreach (var kvp in _originalValues) - { - Environment.SetEnvironmentVariable(kvp.Key, kvp.Value); - } - } -} - -public abstract class DisposableConfig : IDisposable -{ - private readonly SemaphoreSlim _lock; - // Common environment variables to be saved off for tests. Add more as needed - protected readonly Dictionary _originalValues = new(); - - public DisposableConfig(string name, string value, SemaphoreSlim sem) - { - _lock = sem; - var acquired = _lock.Wait(TimeSpan.Zero); - if (!acquired) - { - throw new Exception($"Concurrent use of {nameof(TestEnvVar)}. Consider marking these tests to not run in parallel."); - } - - InitValues(); - SetValue(name, value); - } - - public DisposableConfig(Dictionary values, SemaphoreSlim sem) - { - _lock = sem; - var acquired = _lock.Wait(TimeSpan.Zero); - if (!acquired) - { - throw new Exception($"Concurrent use of {nameof(TestEnvVar)}. Consider marking these tests to not run in parallel."); - } - - InitValues(); - SetValues(values); - } - - internal abstract void SetValue(string name, string value); - internal abstract void SetValues(Dictionary values); - internal abstract void InitValues(); - internal abstract void Cleanup(); - - public void Dispose() - { - Cleanup(); - _lock.Release(); - } -} diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/TestExtensions.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/TestExtensions.cs index a964f331b3..8879d76a88 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/TestExtensions.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/TestExtensions.cs @@ -32,6 +32,7 @@ public static JsonElement AssertProperty(this JsonElement? element, string prope Assert.NotNull(element); return element.Value.AssertProperty(propertyName); } + public static JsonElement AssertProperty(this JsonElement element, string propertyName) { Assert.True(element.TryGetProperty(propertyName, out var property), $"Property '{propertyName}' not found. Full element: '{JsonSerializer.Serialize(element)}'"); diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/tsp-location.yaml b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/tsp-location.yaml new file mode 100644 index 0000000000..1b43817039 --- /dev/null +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/tsp-location.yaml @@ -0,0 +1,3 @@ +repo: Azure/azure-sdk-tools +commit: 2360f015aba8be6941f31c7ca936d40677638116 +directory: tools/test-proxy/typespec diff --git a/core/Azure.Mcp.Core/src/Models/Option/OptionDefinition.cs b/core/Microsoft.Mcp.Core/src/Models/Option/OptionDefinition.cs similarity index 95% rename from core/Azure.Mcp.Core/src/Models/Option/OptionDefinition.cs rename to core/Microsoft.Mcp.Core/src/Models/Option/OptionDefinition.cs index ebc7ce66e1..df7001fb13 100644 --- a/core/Azure.Mcp.Core/src/Models/Option/OptionDefinition.cs +++ b/core/Microsoft.Mcp.Core/src/Models/Option/OptionDefinition.cs @@ -1,9 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.CommandLine; using System.Text.Json.Serialization; -namespace Azure.Mcp.Core.Models.Option; +namespace Microsoft.Mcp.Core.Models.Option; public class OptionDefinition(string name, string description, string? value = "", T? defaultValue = default, bool required = false, bool hidden = false) where T : notnull diff --git a/core/Azure.Mcp.Core/src/Models/Option/OptionExtensions.cs b/core/Microsoft.Mcp.Core/src/Models/Option/OptionExtensions.cs similarity index 96% rename from core/Azure.Mcp.Core/src/Models/Option/OptionExtensions.cs rename to core/Microsoft.Mcp.Core/src/Models/Option/OptionExtensions.cs index db0e350662..f0bfc3f866 100644 --- a/core/Azure.Mcp.Core/src/Models/Option/OptionExtensions.cs +++ b/core/Microsoft.Mcp.Core/src/Models/Option/OptionExtensions.cs @@ -1,7 +1,9 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace Azure.Mcp.Core.Models.Option; +using System.CommandLine; + +namespace Microsoft.Mcp.Core.Models.Option; /// /// Extension methods for working with option definitions and command registration. diff --git a/core/Azure.Mcp.Core/src/Models/Option/OptionInfo.cs b/core/Microsoft.Mcp.Core/src/Models/Option/OptionInfo.cs similarity index 87% rename from core/Azure.Mcp.Core/src/Models/Option/OptionInfo.cs rename to core/Microsoft.Mcp.Core/src/Models/Option/OptionInfo.cs index ee28e22735..dae9fabf85 100644 --- a/core/Azure.Mcp.Core/src/Models/Option/OptionInfo.cs +++ b/core/Microsoft.Mcp.Core/src/Models/Option/OptionInfo.cs @@ -1,7 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -namespace Azure.Mcp.Core.Models.Option; +namespace Microsoft.Mcp.Core.Models.Option; public class OptionInfo( string name, diff --git a/docs/Authentication.md b/docs/Authentication.md index 254c63d10e..94a7f0e6fe 100644 --- a/docs/Authentication.md +++ b/docs/Authentication.md @@ -2,9 +2,38 @@ This document provides comprehensive guidance for Azure MCP Server authentication and related security considerations in enterprise environments. While the core focus is authentication, it also covers network and security configurations that commonly affect authentication in enterprise scenarios. +## Overview + +Azure MCP Server authenticates to Microsoft Entra ID via the [Azure Identity library for .NET](https://learn.microsoft.com/dotnet/azure/sdk/authentication/). + +> [!TIP] +> In VS Code, after installing the Azure MCP Server extension, open the Command Palette (`Ctrl+Shift+P` or `Cmd+Shift+P` on Mac), then run "Azure: Sign In" to authenticate quickly. + +The server supports two authentication modes: **broker mode** (which uses your OS's native authentication system like Windows Web Account Manager for enhanced security) and **credential chain mode** (which tries multiple authentication methods in sequence). Here is an overview of how authentication works: + +```mermaid +flowchart TD + C{"Broker mode?"} -- yes --> D["OS broker"] + C -- no --> E["EnvironmentCredential"] + E -- failed --> n8["VisualStudioCredential"] + E -- succeeded --> n17["Authenticated to Azure"] + n8 -- failed --> n12["AzureCliCredential"] + n8 -- succeeded --> n17 + n12 -- failed --> n13["AzurePowerShellCredential"] + n12 -- succeeded --> n17 + n13 -- failed --> n14["AzureDeveloperCliCredential"] + n13 -- succeeded --> n17 + n14 -- failed --> n15["InteractiveBrowserCredential"] + n14 -- succeeded --> n17 + n15 -- failed --> n16["Authentication failed"] + n15 -- succeeded --> n17 + n17@{ shape: event } + n16@{ shape: event } +``` + ## Authentication Fundamentals -Azure MCP Server authenticates to Microsoft Entra ID via the [Azure Identity library for .NET](https://learn.microsoft.com/dotnet/azure/sdk/authentication/). If environment variable `AZURE_MCP_ONLY_USE_BROKER_CREDENTIAL` is: +If environment variable `AZURE_MCP_ONLY_USE_BROKER_CREDENTIAL` is: - Set to `true`, a broker-enabled instance of `InteractiveBrowserCredential` is used to authenticate. On Windows, the broker is Web Account Manager. If a broker isn't supported on your operating system, the credential degrades gracefully to a browser-based login experience. For more information on this approach, see [Interactive brokered authentication](https://learn.microsoft.com/dotnet/azure/sdk/authentication/additional-methods#interactive-brokered-authentication). - Not set, a custom [chain of credentials](https://learn.microsoft.com/dotnet/azure/sdk/authentication/credential-chains?tabs=dac#how-a-chained-credential-works) is used to authenticate. The chain is designed to support many environments, along with the most common authentication flows and developer tools. When one credential fails to acquire a token, the chain attempts the next credential. In Azure MCP Server, the chain is configured as follows by default: @@ -20,19 +49,28 @@ Azure MCP Server authenticates to Microsoft Entra ID via the [Azure Identity lib | 7 | [AzureDeveloperCliCredential](https://learn.microsoft.com/dotnet/api/azure.identity.azuredeveloperclicredential?view=azure-dotnet) | Uses your Azure Developer CLI login | Yes | | 8 | [InteractiveBrowserCredential](https://learn.microsoft.com/dotnet/api/azure.identity.interactivebrowsercredential?view=azure-dotnet) | Uses a broker and falls back to browser-based login if needed. The account picker dialog allows you to ensure you're selecting the correct account. | Yes | + **Note:** `InteractiveBrowserCredential` is included as a fallback only in default and "dev" credential chains. When using `AZURE_TOKEN_CREDENTIALS=prod` or specifying a specific credential, the interactive browser credential is not added, ensuring production scenarios fail fast if authentication is unavailable. + If you're logged in through any of these mechanisms, the Azure MCP Server will automatically use those credentials. Ensure that you have the correct authorization permissions in Azure. For example, read access to your Storage account via Role-Based Access Control (RBAC). To learn more about Azure's RBAC authorization system, see [What is Azure RBAC?](https://learn.microsoft.com/azure/role-based-access-control/overview). ## Recommended Authentication Configuration by Environment ### Production Environments -For Kubernetes workloads or Azure-hosted apps, set the following environment variable to `true`: +For Kubernetes workloads or Azure-hosted apps (Web Apps, Function Apps, Container Apps, AKS), set the following environment variable: + +```bash +export AZURE_TOKEN_CREDENTIALS=prod +``` + +This configuration modifies the credential chain to use only production credentials (Environment, Workload Identity, and Managed Identity), in that order. The `InteractiveBrowserCredential` is NOT included, ensuring authentication fails fast if none of the production credentials are available. +**For User-Assigned Managed Identity**, also set: ```bash -export AZURE_MCP_INCLUDE_PRODUCTION_CREDENTIALS=true +export AZURE_CLIENT_ID= ``` -This configuration modifies the credential chain to enable authentication via workload identity and managed identity, in that order. +If `AZURE_CLIENT_ID` is not set, System-Assigned Managed Identity will be used. ### Development Environments @@ -44,6 +82,8 @@ az login az account show ``` +The default credential chain includes `InteractiveBrowserCredential` as a fallback for development convenience. + ### CI/CD Pipelines For automated builds and deployments, set the following environment variables: @@ -113,6 +153,68 @@ When local authentication is disabled, Azure MCP Server must use Microsoft Entra - Is there a preferred authentication method (user vs. service principal)? - Are there network restrictions (private endpoints, firewall rules)? +### Azure Cosmos DB (RBAC for SQL data plane) + +Azure Cosmos DB supports data plane access via Microsoft Entra ID (RBAC). If key-based authentication is disabled (recommended), grant a Microsoft Entra user or service principal a built-in data role at the Cosmos account scope. + +Prerequisites: +- Azure CLI installed (`az version`) +- Logged in to the correct tenant/subscription (`az login`) +- Resource group and account name for the Cosmos DB account + +Role options (built-in): +- Data Contributor: full read/write data access — role ID `00000000-0000-0000-0000-000000000002` +- Data Reader: read-only data access — role ID `00000000-0000-0000-0000-000000000001` + +PowerShell example (assign Data Contributor to a user): + +```powershell +$user = 'user@contoso.com' +$resourceGroup = 'rg-name' +$account = 'cosmos-account-name' + +# Account scope +$resourceId = az cosmosdb show -g $resourceGroup -n $account --query "id" -o tsv + +# Built-in Data Contributor role +$roleId = az cosmosdb sql role definition show -a $account -g $resourceGroup -i 00000000-0000-0000-0000-000000000002 --query id -o tsv + +# Principal object ID (user) +$principalId = az ad user show --id $user --query 'id' -o tsv + +az cosmosdb sql role assignment create --resource-group $resourceGroup --account-name $account --principal-id $principalId --role-definition-id $roleId --scope $resourceId +``` + +Bash example (assign Data Reader to a service principal): + +```bash +spAppId="00000000-0000-0000-0000-000000000000" # replace with your app (client) ID +resourceGroup="rg-name" +account="cosmos-account-name" + +# Account scope +resourceId=$(az cosmosdb show -g "$resourceGroup" -n "$account" --query id -o tsv) + +# Built-in Data Reader role +roleId=$(az cosmosdb sql role definition show -a "$account" -g "$resourceGroup" -i 00000000-0000-0000-0000-000000000001 --query id -o tsv) + +# Principal object ID (service principal) +principalId=$(az ad sp show --id "$spAppId" --query id -o tsv) + +az cosmosdb sql role assignment create \ + --resource-group "$resourceGroup" \ + --account-name "$account" \ + --principal-id "$principalId" \ + --role-definition-id "$roleId" \ + --scope "$resourceId" +``` + +Notes: +- Scope can be set at the account level (as above) or narrowed to database/container scopes if needed. +- RBAC propagation may take several minutes after assignment. +- Use the Reader role for read-only scenarios and Contributor for read/write tooling. +- Ensure you authenticate via one of the supported credentials (for example, Azure CLI — `az login`) before using Cosmos tools in Azure MCP. + ### Authentication Through Network Restrictions Organizations often implement network restrictions that can affect Azure MCP Server's ability to authenticate and access resources. While these are network security configurations, they directly impact authentication flows. diff --git a/docs/azmcp-commands.md b/docs/azmcp-commands.md deleted file mode 100644 index 110c534d69..0000000000 --- a/docs/azmcp-commands.md +++ /dev/null @@ -1,1405 +0,0 @@ -# Azure MCP CLI Command Reference - -> [!IMPORTANT] -> The Azure MCP Server has two modes: MCP Server mode and CLI mode. When you start the MCP Server with `azmcp server start` that will expose an endpoint for MCP Client communication. The `azmcp` CLI also exposes all of the Tools via a command line interface, i.e. `azmcp subscription list`. Since `azmcp` is built on a CLI infrastructure, you'll see the word "Command" be used interchangeably with "Tool". - -## Global Options - -The following options are available for all commands: - -| Option | Required | Default | Description | -|-----------|----------|---------|-------------| -| `--subscription` | No | Environment variable `AZURE_SUBSCRIPTION_ID` | Azure subscription ID for target resources | -| `--tenant-id` | No | - | Azure tenant ID for authentication | -| `--auth-method` | No | 'credential' | Authentication method ('credential', 'key', 'connectionString') | -| `--retry-max-retries` | No | 3 | Maximum retry attempts for failed operations | -| `--retry-delay` | No | 2 | Delay between retry attempts (seconds) | -| `--retry-max-delay` | No | 10 | Maximum delay between retries (seconds) | -| `--retry-mode` | No | 'exponential' | Retry strategy ('fixed' or 'exponential') | -| `--retry-network-timeout` | No | 100 | Network operation timeout (seconds) | - -## Available Commands - -### Server Operations - -The Azure MCP Server can be started in several different modes depending on how you want to expose the Azure tools: - -#### Default Mode (Namespace) - -Exposes Azure tools grouped by service namespace. Each Azure service appears as a single namespace-level tool that routes to individual operations internally. This is the default mode to reduce tool count and prevent VS Code from hitting the 128 tool limit. - -```bash -# Start MCP Server with namespace-level tools (default behavior) -azmcp server start \ - [--transport ] \ - [--read-only] - -# Explicitly specify namespace mode -azmcp server start \ - --mode namespace \ - [--transport ] \ - [--read-only] -``` - -#### All Tools Mode - -Exposes all Azure tools individually. Each Azure service operation appears as a separate MCP tool. - -```bash -# Start MCP Server with all tools exposed individually -azmcp server start \ - --mode all \ - [--transport ] \ - [--read-only] -``` - -#### Single Tool Mode - -Exposes a single "azure" tool that handles internal routing across all Azure MCP tools. - -```bash -# Start MCP Server with single azure tool -azmcp server start \ - --mode single \ - [--transport ] \ - [--read-only] -``` - -#### Namespace Filtering - -Exposes only tools for specific Azure service namespaces. Use multiple `--namespace` parameters to include multiple namespaces. - -```bash -# Start MCP Server with only Storage tools -azmcp server start \ - --namespace storage \ - --mode all \ - [--transport ] \ - [--read-only] - -# Start MCP Server with Storage and Key Vault tools -azmcp server start \ - --namespace storage \ - --namespace keyvault \ - --mode all \ - [--transport ] \ - [--read-only] -``` - -#### Namespace Mode (Default) - -Collapses all tools within each namespace into a single tool (e.g., all storage operations become one "storage" tool with internal routing). This mode is particularly useful when working with MCP clients that have tool limits - for example, VS Code only supports a maximum of 128 tools across all registered MCP servers. - -```bash -# Start MCP Server with service proxy tools -azmcp server start \ - --mode namespace \ - [--transport ] \ - [--read-only] -``` - -#### Single Tool Proxy Mode - -Exposes a single "azure" tool that handles internal routing across all Azure MCP tools. - -```bash -# Start MCP Server with single Azure tool proxy -azmcp server start \ - --mode single \ - [--transport ] \ - [--read-only] -``` - -> **Note:** -> -> - For namespace mode, replace `` with available top level command groups. Run `azmcp -h` to review available namespaces. Examples include `storage`, `keyvault`, `cosmos`, `monitor`, etc. -> - The `--read-only` flag applies to all modes and filters the tool list to only contain tools that provide read-only operations. -> - Multiple `--namespace` parameters can be used together to expose tools for multiple specific namespaces. -> - The `--namespace` and `--mode` parameters can also be combined to provide a unique running mode based on the desired scenario. - -#### Server Start Command Options - -The `azmcp server start` command supports the following options: - -| Option | Required | Default | Description | -|--------|----------|---------|-------------| -| `--transport` | No | `stdio` | Transport mechanism to use (currently only `stdio` is supported) | -| `--mode` | No | `namespace` | Server mode: `namespace` (default), `all`, or `single` | -| `--namespace` | No | All namespaces | Specific Azure service namespaces to expose (can be repeated) | -| `--read-only` | No | `false` | Only expose read-only operations | -| `--debug` | No | `false` | Enable verbose debug logging to stderr | -| `--insecure-disable-elicitation` | No | `false` | **⚠️ INSECURE**: Disable user consent prompts for sensitive operations | - -> **⚠️ Security Warning for `--insecure-disable-elicitation`:** -> -> This option disables user confirmations (elicitations) before running tools that read sensitive data. When enabled: -> - Tools that handle secrets, credentials, or sensitive data will execute without user confirmation -> - This removes an important security layer designed to prevent unauthorized access to sensitive information -> - Only use this option in trusted, automated environments where user interaction is not possible -> - Never use this option in production environments or when handling untrusted input -> -> **Example usage (use with caution):** -> ```bash -> # For automated scenarios only - bypasses security prompts -> azmcp server start --insecure-disable-elicitation -> ``` - -### Azure AI Foundry Operations - -```bash - -# Connect to an agent in an AI Foundry project and query it -azmcp foundry agents connect --agent-id \ - --query \ - --endpoint - -# Evaluate a response from an agent by passing query and response inline -azmcp foundry agents evaluate --agent-id \ - --query \ - --response \ - --evaluator \ - --azure-openai-endpoint \ - --azure-openai-deployment \ - [--tool-definitions ] - -# Query and evaluate an agent in one command -azmcp foundry agents query-and-evaluate --agent-id \ - --query \ - --endpoint \ - --azure-openai-endpoint \ - --azure-openai-deployment \ - [--evaluators ] - -# List knowledge indexes in an AI Foundry project -azmcp foundry knowledge index list --endpoint - -# Get knowledge index schema information -azmcp foundry knowledge index schema --endpoint \ - --index - -# Deploy an AI Foundry model -azmcp foundry models deploy --subscription \ - --resource-group \ - --deployment \ - --model-name \ - --model-format \ - --azure-ai-services \ - [--model-version ] \ - [--model-source ] \ - [--sku ] \ - [--sku-capacity ] \ - [--scale-type ] \ - [--scale-capacity ] - -# List AI Foundry model deployments -azmcp foundry models deployments list --endpoint - -# List AI Foundry models -azmcp foundry models list [--search-for-free-playground ] \ - [--publisher ] \ - [--license ] \ - [--model-name ] -``` - -### Azure AI Search Operations - -```bash -# Get detailed properties of AI Search indexes -azmcp search index get --service \ - [--index ] - -# Query AI Search index -azmcp search index query --subscription \ - --service \ - --index \ - --query - -# List AI Search accounts in a subscription -azmcp search list --subscription -``` - -### Azure App Configuration Operations - -```bash -# List App Configuration stores in a subscription -azmcp appconfig account list --subscription - -# Delete a key-value setting -azmcp appconfig kv delete --subscription \ - --account \ - --key \ - [--label