diff --git a/src/AppInstallerCLIE2ETests/AppInstallerCLIE2ETests.csproj b/src/AppInstallerCLIE2ETests/AppInstallerCLIE2ETests.csproj
index a99a99e245..a9aa4f10dd 100644
--- a/src/AppInstallerCLIE2ETests/AppInstallerCLIE2ETests.csproj
+++ b/src/AppInstallerCLIE2ETests/AppInstallerCLIE2ETests.csproj
@@ -26,6 +26,7 @@
+
diff --git a/src/AppInstallerCLIE2ETests/Constants.cs b/src/AppInstallerCLIE2ETests/Constants.cs
index 5ee25daf69..0624ec9296 100644
--- a/src/AppInstallerCLIE2ETests/Constants.cs
+++ b/src/AppInstallerCLIE2ETests/Constants.cs
@@ -36,6 +36,7 @@ public class Constants
public const string InprocTestbedUseTestPackageParameter = "InprocTestbedUseTestPackage";
public const string WinGetServerPathParameter = "WinGetServerPath";
public const string RpcTestHelperPathParameter = "RpcTestHelperPath";
+ public const string MCPServerPathParameter = "MCPServerPath";
// Test Sources
public const string DefaultWingetSourceName = @"winget";
diff --git a/src/AppInstallerCLIE2ETests/Helpers/AgreementsRestSource.cs b/src/AppInstallerCLIE2ETests/Helpers/AgreementsRestSource.cs
new file mode 100644
index 0000000000..51b3500705
--- /dev/null
+++ b/src/AppInstallerCLIE2ETests/Helpers/AgreementsRestSource.cs
@@ -0,0 +1,124 @@
+// -----------------------------------------------------------------------------
+//
+// Copyright (c) Microsoft Corporation. Licensed under the MIT License.
+//
+// -----------------------------------------------------------------------------
+
+namespace AppInstallerCLIE2ETests.Helpers
+{
+ using System.IO;
+ using NUnit.Framework;
+
+ ///
+ /// A REST source with source agreements, served from a generated information file under the static file root.
+ /// Only the information endpoint is served, so the source cannot be searched.
+ ///
+ public class AgreementsRestSource
+ {
+ ///
+ /// The label of the source agreement.
+ ///
+ public const string AgreementLabel = "Test Agreement";
+
+ ///
+ /// The text of the source agreement.
+ ///
+ public const string AgreementText = "Test agreement text";
+
+ ///
+ /// The URL of the source agreement.
+ ///
+ public const string AgreementUrl = "https://localhost:5001/TestKit/agreement";
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The name of the source, also used as its directory under the static file root.
+ public AgreementsRestSource(string name)
+ {
+ this.Name = name;
+ }
+
+ ///
+ /// Gets the name of the source.
+ ///
+ public string Name { get; }
+
+ ///
+ /// Gets the URL of the source.
+ ///
+ public string Url => "https://localhost:5001/TestKit/" + this.Name;
+
+ private string InformationFilePath => Path.Combine(TestSetup.Parameters.StaticFileRootPath, this.Name, "information");
+
+ ///
+ /// Writes the information served by the source.
+ ///
+ /// The agreements identifier, or null for a source without agreements.
+ /// Whether the source requires the market, which is an implicit agreement.
+ /// Whether the agreements identifier is accompanied by an agreement.
+ public void WriteInformation(string agreementsIdentifier, bool requiresMarket, bool includeAgreements = true)
+ {
+ string agreementEntries = includeAgreements ? $@"{{
+ ""AgreementLabel"": ""{AgreementLabel}"",
+ ""Agreement"": ""{AgreementText}"",
+ ""AgreementUrl"": ""{AgreementUrl}""
+ }}" : string.Empty;
+
+ string agreements = agreementsIdentifier == null ? string.Empty : $@",
+ ""SourceAgreements"": {{
+ ""AgreementsIdentifier"": ""{agreementsIdentifier}"",
+ ""Agreements"": [{agreementEntries}]
+ }}";
+
+ string market = requiresMarket ? @",
+ ""RequiredPackageMatchFields"": [ ""Market"" ]" : string.Empty;
+
+ string information = $@"{{
+ ""Data"": {{
+ ""SourceIdentifier"": ""{this.Name}"",
+ ""ServerSupportedVersions"": [ ""1.0.0"" ]{agreements}{market}
+ }}
+}}";
+
+ Directory.CreateDirectory(Path.GetDirectoryName(this.InformationFilePath));
+ File.WriteAllText(this.InformationFilePath, information);
+ }
+
+ ///
+ /// Adds the source with the CLI and then changes its agreements, so that they are not accepted.
+ /// Adding a source accepts its current agreements.
+ ///
+ /// Whether the source requires the market, which is an implicit agreement.
+ public void AddWithUnacceptedAgreements(bool requiresMarket = false)
+ {
+ this.WriteInformation("AgreementsV1", requiresMarket);
+ var result = TestCommon.RunAICLICommand("source add", $"{this.Name} {this.Url} --type {Constants.RestTestSourceType} --trust-level trusted --accept-source-agreements");
+ Assert.That(result.ExitCode, Is.EqualTo(Constants.ErrorCode.S_OK), result.StdOut);
+
+ this.WriteInformation("AgreementsV2", requiresMarket);
+ }
+
+ ///
+ /// Deletes the information served by the source, so that requests for it fail.
+ ///
+ public void DeleteInformation()
+ {
+ File.Delete(this.InformationFilePath);
+ }
+
+ ///
+ /// Removes the source and its information file.
+ ///
+ public void Remove()
+ {
+ TestCommon.RunAICLICommand("source remove", this.Name);
+
+ var directory = Path.GetDirectoryName(this.InformationFilePath);
+ if (Directory.Exists(directory))
+ {
+ Directory.Delete(directory, true);
+ }
+ }
+ }
+}
diff --git a/src/AppInstallerCLIE2ETests/Helpers/TestSetup.cs b/src/AppInstallerCLIE2ETests/Helpers/TestSetup.cs
index 2f5bbb86e5..f61676244a 100644
--- a/src/AppInstallerCLIE2ETests/Helpers/TestSetup.cs
+++ b/src/AppInstallerCLIE2ETests/Helpers/TestSetup.cs
@@ -49,6 +49,7 @@ private TestSetup()
this.InprocTestbedPath = this.InitializeFileParam(Constants.InprocTestbedPathParameter);
this.WinGetServerPath = this.InitializeStringParam(Constants.WinGetServerPathParameter);
this.RpcTestHelperPath = this.InitializeStringParam(Constants.RpcTestHelperPathParameter);
+ this.MCPServerPath = this.InitializeStringParam(Constants.MCPServerPathParameter, "WindowsPackageManagerMCPServerDev.exe");
this.ForcedExperimentalFeatures = this.InitializeStringArrayParam(Constants.ForcedExperimentalFeaturesParameter);
}
@@ -149,6 +150,11 @@ public static TestSetup Parameters
///
public string RpcTestHelperPath { get; }
+ ///
+ /// Gets the command used to start the MCP server for the MCP tests.
+ ///
+ public string MCPServerPath { get; }
+
///
/// Gets a value indicating whether to use the test package or not.
///
diff --git a/src/AppInstallerCLIE2ETests/Interop/SourceAgreementsInterop.cs b/src/AppInstallerCLIE2ETests/Interop/SourceAgreementsInterop.cs
new file mode 100644
index 0000000000..cd29f6c177
--- /dev/null
+++ b/src/AppInstallerCLIE2ETests/Interop/SourceAgreementsInterop.cs
@@ -0,0 +1,153 @@
+// -----------------------------------------------------------------------------
+//
+// Copyright (c) Microsoft Corporation. Licensed under the MIT License.
+//
+// -----------------------------------------------------------------------------
+namespace AppInstallerCLIE2ETests.Interop
+{
+ using System;
+ using System.Threading.Tasks;
+ using AppInstallerCLIE2ETests.Helpers;
+ using Microsoft.Management.Deployment;
+ using Microsoft.Management.Deployment.Projection;
+ using NUnit.Framework;
+
+ ///
+ /// Tests for the source agreement acceptance state on PackageCatalogReference.
+ ///
+ [TestFixtureSource(typeof(InstanceInitializersSource), nameof(InstanceInitializersSource.InProcess), Category = nameof(InstanceInitializersSource.InProcess))]
+ [TestFixtureSource(typeof(InstanceInitializersSource), nameof(InstanceInitializersSource.OutOfProcess), Category = nameof(InstanceInitializersSource.OutOfProcess))]
+ public class SourceAgreementsInterop : BaseInterop
+ {
+ private const int ErrorIllegalStateChange = unchecked((int)0x8000000D);
+
+ private readonly AgreementsRestSource source = new AgreementsRestSource("TestAgreementsRestSource");
+
+ private PackageManager packageManager;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// initializer.
+ public SourceAgreementsInterop(IInstanceInitializer initializer)
+ : base(initializer)
+ {
+ }
+
+ ///
+ /// Set up.
+ ///
+ [SetUp]
+ public void Setup()
+ {
+ this.source.Remove();
+ this.packageManager = this.TestFactory.CreatePackageManager();
+ }
+
+ ///
+ /// Tear down.
+ ///
+ [TearDown]
+ public void TearDown()
+ {
+ this.source.Remove();
+ }
+
+ ///
+ /// A source without agreements or implicit agreement fields is satisfied.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task NoAgreements_Satisfied()
+ {
+ this.source.WriteInformation(agreementsIdentifier: null, requiresMarket: false);
+ var catalogRef = await this.AddCatalogAsync();
+
+ Assert.That(catalogRef.SourceAgreements.Count, Is.EqualTo(0));
+ Assert.That(catalogRef.ImplicitAgreementFields, Is.EqualTo(ImplicitAgreementFields.None));
+ Assert.That(catalogRef.SourceAgreementsSatisfied, Is.True);
+ }
+
+ ///
+ /// Adding a source accepts its agreements; changing them requires acceptance again, which is persisted by SaveAcceptedSourceAgreements.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task ChangedAgreements_NotSatisfiedUntilSaved()
+ {
+ this.source.WriteInformation("AgreementsV1", requiresMarket: true);
+ var catalogRef = await this.AddCatalogAsync();
+
+ Assert.That(catalogRef.SourceAgreements.Count, Is.EqualTo(1));
+ Assert.That(catalogRef.ImplicitAgreementFields, Is.EqualTo(ImplicitAgreementFields.Market));
+ Assert.That(catalogRef.SourceAgreementsSatisfied, Is.True, "Adding a source records acceptance of its agreements.");
+
+ // A new reference is needed, as the source information is retrieved once per reference.
+ this.source.WriteInformation("AgreementsV2", requiresMarket: true);
+ catalogRef = this.packageManager.GetPackageCatalogByName(this.source.Name);
+ Assert.That(catalogRef.SourceAgreementsSatisfied, Is.False);
+
+ catalogRef.SaveAcceptedSourceAgreements();
+ Assert.That(catalogRef.SourceAgreementsSatisfied, Is.True);
+
+ // Verify that the acceptance was persisted.
+ catalogRef = this.packageManager.GetPackageCatalogByName(this.source.Name);
+ Assert.That(catalogRef.SourceAgreementsSatisfied, Is.True);
+ }
+
+ ///
+ /// A source that changes its agreements identifier without declaring any agreements has nothing to accept.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task EmptyAgreements_Satisfied()
+ {
+ this.source.WriteInformation("AgreementsV1", requiresMarket: false);
+ await this.AddCatalogAsync();
+
+ this.source.WriteInformation("AgreementsV2", requiresMarket: false, includeAgreements: false);
+ var catalogRef = this.packageManager.GetPackageCatalogByName(this.source.Name);
+
+ Assert.That(catalogRef.SourceAgreements.Count, Is.EqualTo(0));
+ Assert.That(catalogRef.SourceAgreementsSatisfied, Is.True);
+ }
+
+ ///
+ /// The agreement members are not available on a composite catalog reference.
+ ///
+ /// A representing the asynchronous unit test.
+ [Test]
+ public async Task Composite_Throws()
+ {
+ this.source.WriteInformation("AgreementsV1", requiresMarket: true);
+ var catalogRef = await this.AddCatalogAsync();
+
+ var options = this.TestFactory.CreateCreateCompositePackageCatalogOptions();
+ options.Catalogs.Add(catalogRef);
+ options.CompositeSearchBehavior = CompositeSearchBehavior.AllCatalogs;
+ var compositeRef = this.packageManager.CreateCompositePackageCatalog(options);
+ Assert.That(compositeRef.IsComposite, Is.True);
+
+ Assert.That(() => compositeRef.SourceAgreementsSatisfied, Throws.InstanceOf().With.Property("HResult").EqualTo(ErrorIllegalStateChange));
+ Assert.That(() => compositeRef.ImplicitAgreementFields, Throws.InstanceOf().With.Property("HResult").EqualTo(ErrorIllegalStateChange));
+ Assert.That(() => compositeRef.SaveAcceptedSourceAgreements(), Throws.InstanceOf().With.Property("HResult").EqualTo(ErrorIllegalStateChange));
+ }
+
+ private async Task AddCatalogAsync()
+ {
+ var options = this.TestFactory.CreateAddPackageCatalogOptions();
+ options.Name = this.source.Name;
+ options.SourceUri = this.source.Url;
+ options.Type = Constants.RestTestSourceType;
+ options.TrustLevel = PackageCatalogTrustLevel.Trusted;
+
+ var addResult = await this.packageManager.AddPackageCatalogAsync(options);
+ Assert.That(addResult, Is.Not.Null);
+ Assert.That(addResult.Status, Is.EqualTo(AddPackageCatalogStatus.Ok), $"Failed to add REST test source. Error: {addResult.ExtendedErrorCode?.HResult:X}");
+
+ var catalogRef = this.packageManager.GetPackageCatalogByName(this.source.Name);
+ Assert.That(catalogRef, Is.Not.Null);
+ return catalogRef;
+ }
+ }
+}
diff --git a/src/AppInstallerCLIE2ETests/MCPServerTests.cs b/src/AppInstallerCLIE2ETests/MCPServerTests.cs
new file mode 100644
index 0000000000..eaae2eaf4f
--- /dev/null
+++ b/src/AppInstallerCLIE2ETests/MCPServerTests.cs
@@ -0,0 +1,637 @@
+// -----------------------------------------------------------------------------
+//
+// Copyright (c) Microsoft Corporation. Licensed under the MIT License.
+//
+// -----------------------------------------------------------------------------
+
+namespace AppInstallerCLIE2ETests
+{
+ using System;
+ using System.Collections.Generic;
+ using System.IO;
+ using System.Linq;
+ using System.Text.Json;
+ using System.Threading;
+ using System.Threading.Tasks;
+ using AppInstallerCLIE2ETests.Helpers;
+ using ModelContextProtocol;
+ using ModelContextProtocol.Client;
+ using ModelContextProtocol.Protocol;
+ using NUnit.Framework;
+
+ ///
+ /// Tests that act as an MCP client to the WinGet MCP server.
+ /// Each test starts its own server, as the server keeps per-process consent state.
+ ///
+ public class MCPServerTests : BaseCommand
+ {
+ private const string FindPackagesToolName = "find-winget-packages";
+ private const string InstallPackageToolName = "install-winget-package";
+
+ private const string AgreementsPackageId = "AppInstallerTest.CatalogPackageMetadata";
+
+ private const string CleanupUninstallOptions = " --accept-source-agreements --disable-interactivity";
+ private const string PackageAgreementLabel = "testAgreementLabel";
+ private const string PackageAgreementText = "testAgreementText";
+ private const string PackageAgreementUrl = "https://testAgreementUrl.net";
+
+ private const string AgreementsSourceName = "TestMCPAgreementsRestSource";
+
+ private readonly AgreementsRestSource agreementsSource = new AgreementsRestSource(AgreementsSourceName);
+
+ ///
+ /// Ensures the server can be launched and that the test packages are not installed.
+ ///
+ [SetUp]
+ public void Setup()
+ {
+ if (!TestSetup.Parameters.PackagedContext)
+ {
+ Assert.Ignore("The MCP server requires packaged context.");
+ }
+
+ this.Cleanup();
+ }
+
+ ///
+ /// Removes anything a test may have left behind.
+ ///
+ [TearDown]
+ public void TearDown()
+ {
+ this.Cleanup();
+ }
+
+ ///
+ /// Verifies that the server lists its tools.
+ ///
+ /// A task.
+ [Test]
+ public async Task ListTools()
+ {
+ await using McpClient client = await CreateClientAsync();
+
+ var tools = await client.ListToolsAsync();
+
+ var find = tools.Single(t => t.Name == FindPackagesToolName);
+ var install = tools.Single(t => t.Name == InstallPackageToolName);
+
+ Assert.That(find.ProtocolTool.Annotations?.ReadOnlyHint, Is.True);
+ Assert.That(install.ProtocolTool.Annotations?.ReadOnlyHint, Is.Not.True);
+ }
+
+ ///
+ /// Verifies that finding packages returns the matching package from the test source.
+ ///
+ /// A task.
+ [Test]
+ public async Task FindPackages()
+ {
+ await using McpClient client = await CreateClientAsync();
+
+ CallToolResult result = await client.CallToolAsync(
+ FindPackagesToolName,
+ new Dictionary { ["query"] = Constants.ExeInstallerPackageId });
+
+ Assert.That(result.IsError, Is.Not.True, GetText(result));
+
+ using JsonDocument text = JsonDocument.Parse(GetText(result));
+ Assert.That(text.RootElement.ValueKind, Is.EqualTo(JsonValueKind.Array));
+
+ JsonElement package = text.RootElement.EnumerateArray().Single(p => p.GetProperty("identifier").GetString() == Constants.ExeInstallerPackageId);
+ Assert.That(package.GetProperty("source").GetString(), Is.EqualTo(Constants.TestSourceName));
+ Assert.That(package.GetProperty("isInstalled").GetBoolean(), Is.False);
+
+ Assert.That(result.StructuredContent, Is.Not.Null);
+ Assert.That(result.StructuredContent.Value.TryGetProperty("packages", out JsonElement packages), Is.True);
+ Assert.That(packages.GetArrayLength(), Is.EqualTo(text.RootElement.GetArrayLength()));
+ Assert.That(result.StructuredContent.Value.TryGetProperty("excludedSources", out _), Is.False);
+ }
+
+ ///
+ /// Verifies that a query that matches nothing returns an empty list rather than an error.
+ ///
+ /// A task.
+ [Test]
+ public async Task FindPackages_NoMatches()
+ {
+ await using McpClient client = await CreateClientAsync();
+
+ CallToolResult result = await client.CallToolAsync(
+ FindPackagesToolName,
+ new Dictionary { ["query"] = "MCPTestQueryThatMatchesNothing" });
+
+ Assert.That(result.IsError, Is.Not.True, GetText(result));
+
+ using JsonDocument text = JsonDocument.Parse(GetText(result));
+ Assert.That(text.RootElement.GetArrayLength(), Is.EqualTo(0));
+ }
+
+ ///
+ /// Verifies that a query is required unless searching for upgradeable packages.
+ ///
+ /// A task.
+ [Test]
+ public async Task FindPackages_NoQuery()
+ {
+ await using McpClient client = await CreateClientAsync();
+
+ CallToolResult result = await client.CallToolAsync(
+ FindPackagesToolName,
+ new Dictionary { ["upgradeable"] = false });
+
+ Assert.That(result.IsError, Is.True);
+ }
+
+ ///
+ /// Verifies that a package is installed, reporting progress.
+ ///
+ /// A task.
+ [Test]
+ public async Task InstallPackage()
+ {
+ await using McpClient client = await CreateClientAsync();
+
+ int progressCount = 0;
+ CallToolResult result = await client.CallToolAsync(
+ InstallPackageToolName,
+ new Dictionary { ["identifier"] = Constants.ExeInstallerPackageId, ["source"] = Constants.TestSourceName },
+ progress: new Progress(_ => Interlocked.Increment(ref progressCount)));
+
+ AssertInstalled(result);
+ Assert.That(() => Volatile.Read(ref progressCount), Is.GreaterThan(0).After(5000, 100));
+ }
+
+ ///
+ /// Verifies the response for an identifier that does not match any package.
+ ///
+ /// A task.
+ [Test]
+ public async Task InstallPackage_NotFound()
+ {
+ await using McpClient client = await CreateClientAsync();
+
+ CallToolResult result = await client.CallToolAsync(
+ InstallPackageToolName,
+ new Dictionary { ["identifier"] = "AppInstallerTest.MCPDoesNotExist", ["source"] = Constants.TestSourceName });
+
+ Assert.That(GetStructuredString(result, "message"), Does.Contain("Did not find a package"));
+ Assert.That(GetStructuredString(result, "identifier"), Is.EqualTo("AppInstallerTest.MCPDoesNotExist"));
+ }
+
+ ///
+ /// Verifies that an upgrade is not attempted for a package that is not installed.
+ ///
+ /// A task.
+ [Test]
+ public async Task InstallPackage_UpgradeOnlyNotInstalled()
+ {
+ await using McpClient client = await CreateClientAsync();
+
+ CallToolResult result = await client.CallToolAsync(
+ InstallPackageToolName,
+ new Dictionary { ["identifier"] = Constants.ExeInstallerPackageId, ["source"] = Constants.TestSourceName, ["upgradeOnly"] = true });
+
+ Assert.That(result.IsError, Is.True);
+ Assert.That(GetStructuredString(result, "message"), Does.Contain("not installed"));
+ }
+
+ ///
+ /// Verifies that package agreements are returned to a client without elicitation, and that the
+ /// package is installed once the caller conveys the user's acceptance.
+ ///
+ /// A task.
+ [Test]
+ public async Task PackageAgreements_Fallback_Accepted()
+ {
+ await using McpClient client = await CreateClientAsync();
+
+ CallToolResult result = await InstallAgreementsPackageAsync(client, acceptPackageAgreements: null);
+ AssertPackageAgreementsRequired(result);
+
+ result = await InstallAgreementsPackageAsync(client, acceptPackageAgreements: true);
+ AssertInstalled(result);
+ }
+
+ ///
+ /// Verifies that a caller cannot accept package agreements that it was never given.
+ ///
+ /// A task.
+ [Test]
+ public async Task PackageAgreements_Fallback_AcceptedWithoutPresentation()
+ {
+ await using McpClient client = await CreateClientAsync();
+
+ CallToolResult result = await InstallAgreementsPackageAsync(client, acceptPackageAgreements: true);
+ AssertPackageAgreementsRequired(result);
+ }
+
+ ///
+ /// Verifies that the package is not installed when the user declines its agreements.
+ ///
+ /// A task.
+ [Test]
+ public async Task PackageAgreements_Fallback_Declined()
+ {
+ await using McpClient client = await CreateClientAsync();
+
+ CallToolResult result = await InstallAgreementsPackageAsync(client, acceptPackageAgreements: false);
+
+ Assert.That(result.IsError, Is.True);
+ Assert.That(GetStructuredString(result, "message"), Does.Contain("did not accept"));
+ Assert.That(TestCommon.VerifyTestExeInstalled(Path.GetTempPath()), Is.False);
+ }
+
+ ///
+ /// Verifies that the user is asked to accept package agreements through elicitation, even when the caller claims acceptance.
+ ///
+ /// A task.
+ [Test]
+ public async Task PackageAgreements_Elicitation_Accepted()
+ {
+ var elicitation = new ElicitationRecorder(ElicitationRecorder.Accept);
+ await using McpClient client = await CreateClientAsync(elicitation);
+
+ CallToolResult result = await InstallAgreementsPackageAsync(client, acceptPackageAgreements: true);
+
+ Assert.That(elicitation.Requests.Count, Is.EqualTo(1));
+ Assert.That(elicitation.Requests[0].Message, Does.Contain(PackageAgreementLabel));
+ Assert.That(elicitation.Requests[0].Message, Does.Contain(PackageAgreementText));
+ Assert.That(elicitation.Requests[0].Message, Does.Contain(PackageAgreementUrl));
+ AssertInstalled(result);
+ }
+
+ ///
+ /// Verifies that the package is not installed unless the user explicitly accepts its agreements through elicitation.
+ ///
+ /// The elicitation action taken by the user.
+ /// Whether the user checked the acceptance box.
+ /// A task.
+ [TestCase("decline", false)]
+ [TestCase("cancel", false)]
+ [TestCase("accept", false)]
+ public async Task PackageAgreements_Elicitation_NotAccepted(string action, bool checkedBox)
+ {
+ var elicitation = new ElicitationRecorder(ElicitationRecorder.Respond(action, checkedBox));
+ await using McpClient client = await CreateClientAsync(elicitation);
+
+ CallToolResult result = await InstallAgreementsPackageAsync(client, acceptPackageAgreements: null);
+
+ Assert.That(elicitation.Requests.Count, Is.EqualTo(1));
+ Assert.That(result.IsError, Is.True);
+ Assert.That(GetStructuredString(result, "message"), Does.Contain("did not accept"));
+ Assert.That(TestCommon.VerifyTestExeInstalled(Path.GetTempPath()), Is.False);
+ }
+
+ ///
+ /// Verifies that source agreements are returned to a client without elicitation, and that the acceptance is persisted.
+ ///
+ /// A task.
+ [Test]
+ public async Task SourceAgreements_Fallback_Accepted()
+ {
+ this.agreementsSource.AddWithUnacceptedAgreements();
+
+ await using (McpClient client = await CreateClientAsync())
+ {
+ CallToolResult result = await FindTestPackageAsync(client, acceptSourceAgreements: null);
+ AssertSourceAgreementsRequired(result);
+
+ result = await FindTestPackageAsync(client, acceptSourceAgreements: true);
+ Assert.That(HasAgreements(result), Is.False, GetText(result));
+ }
+
+ // A new server must find the acceptance persisted.
+ await using (McpClient client = await CreateClientAsync())
+ {
+ CallToolResult result = await FindTestPackageAsync(client, acceptSourceAgreements: null);
+ Assert.That(HasAgreements(result), Is.False, GetText(result));
+ }
+ }
+
+ ///
+ /// Verifies that declining source agreements excludes that source while still returning results from the others.
+ ///
+ /// A task.
+ [Test]
+ public async Task SourceAgreements_Fallback_Declined()
+ {
+ this.agreementsSource.AddWithUnacceptedAgreements();
+
+ await using McpClient client = await CreateClientAsync();
+
+ CallToolResult result = await FindTestPackageAsync(client, acceptSourceAgreements: false);
+ AssertExcludedAgreementsSource(result);
+ }
+
+ ///
+ /// Verifies that the user is asked to accept source agreements through elicitation, and that the acceptance is persisted.
+ ///
+ /// A task.
+ [Test]
+ public async Task SourceAgreements_Elicitation_Accepted()
+ {
+ this.agreementsSource.AddWithUnacceptedAgreements();
+
+ var elicitation = new ElicitationRecorder(ElicitationRecorder.Accept);
+ await using (McpClient client = await CreateClientAsync(elicitation))
+ {
+ CallToolResult result = await FindTestPackageAsync(client, acceptSourceAgreements: null);
+
+ Assert.That(elicitation.Requests.Count, Is.EqualTo(1));
+ Assert.That(elicitation.Requests[0].Message, Does.Contain(AgreementsSourceName));
+ Assert.That(elicitation.Requests[0].Message, Does.Contain(AgreementsRestSource.AgreementLabel));
+ Assert.That(elicitation.Requests[0].Message, Does.Contain(AgreementsRestSource.AgreementText));
+ Assert.That(elicitation.Requests[0].Message, Does.Contain(AgreementsRestSource.AgreementUrl));
+ Assert.That(GetExcludedSources(result), Does.Not.Contain(AgreementsSourceName));
+ }
+
+ // A new server must not ask again.
+ elicitation = new ElicitationRecorder(ElicitationRecorder.Respond("decline", false));
+ await using (McpClient client = await CreateClientAsync(elicitation))
+ {
+ CallToolResult result = await FindTestPackageAsync(client, acceptSourceAgreements: null);
+
+ Assert.That(elicitation.Requests.Count, Is.EqualTo(0));
+ Assert.That(GetExcludedSources(result), Does.Not.Contain(AgreementsSourceName));
+ }
+ }
+
+ ///
+ /// Verifies that declining source agreements through elicitation excludes that source, and that the user
+ /// is not asked again in the same session unless the caller indicates the user has changed their mind.
+ ///
+ /// A task.
+ [Test]
+ public async Task SourceAgreements_Elicitation_Declined()
+ {
+ this.agreementsSource.AddWithUnacceptedAgreements();
+
+ var elicitation = new ElicitationRecorder(ElicitationRecorder.Respond("decline", false));
+ await using McpClient client = await CreateClientAsync(elicitation);
+
+ CallToolResult result = await FindTestPackageAsync(client, acceptSourceAgreements: null);
+ Assert.That(elicitation.Requests.Count, Is.EqualTo(1));
+ AssertExcludedAgreementsSource(result);
+
+ result = await FindTestPackageAsync(client, acceptSourceAgreements: null);
+ Assert.That(elicitation.Requests.Count, Is.EqualTo(1), "A declined source is not asked about again.");
+ AssertExcludedAgreementsSource(result);
+
+ result = await FindTestPackageAsync(client, acceptSourceAgreements: true);
+ Assert.That(elicitation.Requests.Count, Is.EqualTo(2), "The user is asked again when the caller indicates acceptance.");
+ AssertExcludedAgreementsSource(result);
+ }
+
+ ///
+ /// Verifies that a source whose agreements cannot be retrieved is not used, rather than being used without consent.
+ ///
+ /// The value passed for acceptSourceAgreements.
+ /// A task.
+ [TestCase(null)]
+ [TestCase(true)]
+ public async Task SourceAgreements_Unavailable(bool? acceptSourceAgreements)
+ {
+ this.agreementsSource.AddWithUnacceptedAgreements();
+ this.agreementsSource.DeleteInformation();
+
+ var elicitation = new ElicitationRecorder(ElicitationRecorder.Accept);
+ await using McpClient client = await CreateClientAsync(elicitation);
+
+ CallToolResult result = await FindTestPackageAsync(client, acceptSourceAgreements);
+
+ Assert.That(result.IsError, Is.Not.True, GetText(result));
+ Assert.That(elicitation.Requests.Count, Is.EqualTo(0));
+ Assert.That(GetExcludedSources(result), Is.Empty);
+ Assert.That(GetStructuredStrings(result, "unavailableSources"), Is.EquivalentTo(new[] { AgreementsSourceName }));
+
+ using JsonDocument text = JsonDocument.Parse(GetText(result));
+ Assert.That(text.RootElement.EnumerateArray().Any(p => p.GetProperty("identifier").GetString() == Constants.ExeInstallerPackageId), Is.True, "Results from the remaining sources are returned.");
+ Assert.That(result.Content.OfType().Skip(1).Single().Text, Does.Contain(AgreementsSourceName));
+ }
+
+ ///
+ /// Verifies that a source whose agreements identifier changed without declaring any agreements is used,
+ /// as there is nothing to consent to.
+ ///
+ /// A task.
+ [Test]
+ public async Task SourceAgreements_EmptyAgreements()
+ {
+ this.agreementsSource.AddWithUnacceptedAgreements();
+ this.agreementsSource.WriteInformation("AgreementsV3", requiresMarket: false, includeAgreements: false);
+
+ var elicitation = new ElicitationRecorder(ElicitationRecorder.Respond("decline", false));
+ await using McpClient client = await CreateClientAsync(elicitation);
+
+ CallToolResult result = await FindTestPackageAsync(client, acceptSourceAgreements: null);
+
+ Assert.That(result.IsError, Is.Not.True, GetText(result));
+ Assert.That(elicitation.Requests.Count, Is.EqualTo(0));
+ Assert.That(HasAgreements(result), Is.False, GetText(result));
+ Assert.That(GetExcludedSources(result), Is.Empty);
+ Assert.That(GetStructuredStrings(result, "unavailableSources"), Is.Empty);
+ }
+
+ private static void CleanupTestPackages()
+ {
+ // Other tests may have installed these packages elsewhere, and the test installer's uninstaller
+ // does not remove the marker file that is used to detect an install to the temp directory.
+ TestCommon.RunAICLICommand($"uninstall {CleanupUninstallOptions}", Constants.ExeInstallerPackageId);
+ TestCommon.RunAICLICommand($"uninstall {CleanupUninstallOptions}", AgreementsPackageId);
+ File.Delete(Path.Combine(Path.GetTempPath(), Constants.TestExeInstalledFileName));
+ File.Delete(Path.Combine(Path.GetTempPath(), Constants.TestExeUninstallerFileName));
+ }
+
+ private static Task CreateClientAsync(ElicitationRecorder elicitation = null)
+ {
+ McpClientOptions options = new McpClientOptions();
+ if (elicitation != null)
+ {
+ options.Handlers.ElicitationHandler = elicitation.HandleAsync;
+ }
+
+ return McpClient.CreateAsync(
+ new StdioClientTransport(new StdioClientTransportOptions()
+ {
+ Name = "WinGet",
+ Command = TestSetup.Parameters.MCPServerPath,
+ }),
+ options);
+ }
+
+ private static async Task InstallAgreementsPackageAsync(McpClient client, bool? acceptPackageAgreements)
+ {
+ var arguments = new Dictionary { ["identifier"] = AgreementsPackageId, ["source"] = Constants.TestSourceName };
+ if (acceptPackageAgreements.HasValue)
+ {
+ arguments["acceptPackageAgreements"] = acceptPackageAgreements.Value;
+ }
+
+ return await client.CallToolAsync(InstallPackageToolName, arguments);
+ }
+
+ private static async Task FindTestPackageAsync(McpClient client, bool? acceptSourceAgreements)
+ {
+ var arguments = new Dictionary { ["query"] = Constants.ExeInstallerPackageId };
+ if (acceptSourceAgreements.HasValue)
+ {
+ arguments["acceptSourceAgreements"] = acceptSourceAgreements.Value;
+ }
+
+ return await client.CallToolAsync(FindPackagesToolName, arguments);
+ }
+
+ private static void AssertInstalled(CallToolResult result)
+ {
+ Assert.That(result.IsError, Is.Not.True, GetText(result));
+ Assert.That(GetStructuredString(result, "message"), Is.EqualTo("Install completed successfully"));
+ Assert.That(TestCommon.VerifyTestExeInstalledAndCleanup(Path.GetTempPath()), Is.True);
+ }
+
+ private static void AssertPackageAgreementsRequired(CallToolResult result)
+ {
+ Assert.That(result.IsError, Is.Not.True, GetText(result));
+
+ JsonElement agreement = GetAgreements(result).Single();
+ Assert.That(agreement.GetProperty("kind").GetString(), Is.EqualTo("package"));
+ Assert.That(agreement.GetProperty("package").GetString(), Does.Contain(AgreementsPackageId));
+ Assert.That(agreement.GetProperty("label").GetString(), Is.EqualTo(PackageAgreementLabel));
+ Assert.That(agreement.GetProperty("text").GetString(), Is.EqualTo(PackageAgreementText));
+ Assert.That(agreement.GetProperty("url").GetString(), Is.EqualTo(PackageAgreementUrl));
+ Assert.That(TestCommon.VerifyTestExeInstalled(Path.GetTempPath()), Is.False);
+ }
+
+ private static void AssertSourceAgreementsRequired(CallToolResult result)
+ {
+ Assert.That(result.IsError, Is.Not.True, GetText(result));
+
+ var agreements = GetAgreements(result);
+ Assert.That(agreements.All(a => a.GetProperty("kind").GetString() == "source" && a.GetProperty("source").GetString() == AgreementsSourceName), Is.True, GetText(result));
+
+ JsonElement agreement = agreements.Single(a => a.TryGetProperty("label", out JsonElement label) && label.GetString() == AgreementsRestSource.AgreementLabel);
+ Assert.That(agreement.GetProperty("text").GetString(), Is.EqualTo(AgreementsRestSource.AgreementText));
+ Assert.That(agreement.GetProperty("url").GetString(), Is.EqualTo(AgreementsRestSource.AgreementUrl));
+ }
+
+ private static void AssertExcludedAgreementsSource(CallToolResult result)
+ {
+ Assert.That(result.IsError, Is.Not.True, GetText(result));
+ Assert.That(GetExcludedSources(result), Is.EquivalentTo(new[] { AgreementsSourceName }));
+
+ using JsonDocument text = JsonDocument.Parse(GetText(result));
+ Assert.That(text.RootElement.EnumerateArray().Any(p => p.GetProperty("identifier").GetString() == Constants.ExeInstallerPackageId), Is.True, "Results from the remaining sources are returned.");
+ Assert.That(result.Content.OfType().Skip(1).Single().Text, Does.Contain(AgreementsSourceName));
+ }
+
+ private static string GetText(CallToolResult result)
+ {
+ return result.Content.OfType().FirstOrDefault()?.Text ?? string.Empty;
+ }
+
+ private static string GetStructuredString(CallToolResult result, string propertyName)
+ {
+ Assert.That(result.StructuredContent, Is.Not.Null, GetText(result));
+ Assert.That(result.StructuredContent.Value.TryGetProperty(propertyName, out JsonElement value), Is.True, GetText(result));
+ return value.GetString();
+ }
+
+ private static bool HasAgreements(CallToolResult result)
+ {
+ return result.StructuredContent?.TryGetProperty("agreements", out _) == true;
+ }
+
+ private static List GetAgreements(CallToolResult result)
+ {
+ Assert.That(HasAgreements(result), Is.True, GetText(result));
+ return result.StructuredContent.Value.GetProperty("agreements").EnumerateArray().ToList();
+ }
+
+ private static List GetExcludedSources(CallToolResult result)
+ {
+ return GetStructuredStrings(result, "excludedSources");
+ }
+
+ private static List GetStructuredStrings(CallToolResult result, string propertyName)
+ {
+ if (result.StructuredContent?.TryGetProperty(propertyName, out JsonElement values) == true)
+ {
+ return values.EnumerateArray().Select(e => e.GetString()).ToList();
+ }
+
+ return new List();
+ }
+
+ private void Cleanup()
+ {
+ try
+ {
+ // The source must be removed first: while its agreements are unaccepted, the CLI prompts for
+ // them and the uninstall commands would wait for input until they time out.
+ this.agreementsSource.Remove();
+ }
+ finally
+ {
+ CleanupTestPackages();
+ }
+ }
+
+ ///
+ /// Records elicitation requests and responds to them as configured.
+ ///
+ private sealed class ElicitationRecorder
+ {
+ private readonly Func respond;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Creates the response to each request.
+ public ElicitationRecorder(Func respond)
+ {
+ this.respond = respond;
+ }
+
+ ///
+ /// Gets a responder that accepts with the acceptance box checked.
+ ///
+ public static Func Accept => Respond("accept", true);
+
+ ///
+ /// Gets the requests received.
+ ///
+ public List Requests { get; } = new List();
+
+ ///
+ /// Creates a responder.
+ ///
+ /// The action taken by the user.
+ /// Whether the user checked the acceptance box.
+ /// The responder.
+ public static Func Respond(string action, bool checkedBox)
+ {
+ return () => new ElicitResult()
+ {
+ Action = action,
+ Content = action == "accept" ? new Dictionary { ["accept"] = JsonSerializer.SerializeToElement(checkedBox) } : null,
+ };
+ }
+
+ ///
+ /// Handles an elicitation request.
+ ///
+ /// The request.
+ /// The cancellation token.
+ /// The response.
+ public ValueTask HandleAsync(ElicitRequestParams request, CancellationToken cancellationToken)
+ {
+ lock (this.Requests)
+ {
+ this.Requests.Add(request);
+ }
+
+ return ValueTask.FromResult(this.respond());
+ }
+ }
+ }
+}
diff --git a/src/AppInstallerCLIE2ETests/README.md b/src/AppInstallerCLIE2ETests/README.md
index 27ce488b34..8312b63a8f 100644
--- a/src/AppInstallerCLIE2ETests/README.md
+++ b/src/AppInstallerCLIE2ETests/README.md
@@ -29,6 +29,7 @@ If your tests uses the test source see the [LocalhostWebServer](#LocalhostWebSer
| InprocTestbedUseTestPackage | Bool to indicate whether in-proc COM tests should target the test package. |
| WinGetServerPath | Path to the WindowsPackageManagerServer executable used by the RPC security tests. Defaults to **..\WinGetServer\WindowsPackageManagerServer.exe** relative to the test binaries. |
| RpcTestHelperPath | Path to the WinGetRpcTestHelper executable used by the RPC security tests. Defaults to **..\WinGetRpcTestHelper\WinGetRpcTestHelper.exe** relative to the test binaries. |
+| MCPServerPath | Command used to start the MCP server for the MCP tests. Defaults to the dev package alias **WindowsPackageManagerMCPServerDev.exe**. MCP tests only run in packaged context. |
| ForcedExperimentalFeatures | Pipe-separated list of experimental features to force-enable during test setup. |
| SkipTestSource | I solemnly swear the test won't use the local test source or the source is already set up. |
@@ -54,6 +55,7 @@ Assuming you clone winget-cli in c:\dev, the localhost web server is running in
+
diff --git a/src/AppInstallerCLIE2ETests/SourceCommand.cs b/src/AppInstallerCLIE2ETests/SourceCommand.cs
index ec6efe15f5..1646f28c9a 100644
--- a/src/AppInstallerCLIE2ETests/SourceCommand.cs
+++ b/src/AppInstallerCLIE2ETests/SourceCommand.cs
@@ -39,6 +39,31 @@ public void SourceAdd()
TestCommon.RunAICLICommand("source remove", $"-n SourceTest");
}
+ ///
+ /// Test that a source whose agreements identifier changed without declaring any agreements is used without
+ /// requiring acceptance, as there is nothing to accept.
+ ///
+ [Test]
+ public void SourceWithEmptyAgreements_NotPrompted()
+ {
+ var agreementsSource = new AgreementsRestSource("TestEmptyAgreementsRestSource");
+
+ try
+ {
+ agreementsSource.AddWithUnacceptedAgreements();
+ agreementsSource.WriteInformation("AgreementsV3", requiresMarket: false, includeAgreements: false);
+
+ // Only the agreements outcome is checked, not the search results.
+ var result = TestCommon.RunAICLICommand("search", $"TestPackage -s {agreementsSource.Name} --disable-interactivity");
+ Assert.That(result.ExitCode, Is.Not.EqualTo(Constants.ErrorCode.ERROR_SOURCE_AGREEMENTS_NOT_ACCEPTED), result.StdOut);
+ Assert.That(result.StdOut, Does.Not.Contain("requires that you view the following agreements"));
+ }
+ finally
+ {
+ agreementsSource.Remove();
+ }
+ }
+
///
/// Test source add with trust level.
///
diff --git a/src/AppInstallerCLIE2ETests/Test.runsettings b/src/AppInstallerCLIE2ETests/Test.runsettings
index 3c4d2545db..57be0f4e1d 100644
--- a/src/AppInstallerCLIE2ETests/Test.runsettings
+++ b/src/AppInstallerCLIE2ETests/Test.runsettings
@@ -27,6 +27,7 @@
InprocTestbedUseTestPackage: Bool to indicate whether in-proc COM tests should target the test package.
WinGetServerPath: Path to the WindowsPackageManagerServer executable used by the RPC security tests.
RpcTestHelperPath: Path to the WinGetRpcTestHelper executable used by the RPC security tests.
+ MCPServerPath: Command used to start the MCP server for the MCP tests (packaged context only).
ForcedExperimentalFeatures: Pipe-separated list of experimental features to force-enable during test setup.
SkipTestSource: I solemnly swear the test won't use the local test source or the source is already set up.
-->
@@ -50,6 +51,7 @@
+
diff --git a/src/AppInstallerRepositoryCore/RepositorySource.cpp b/src/AppInstallerRepositoryCore/RepositorySource.cpp
index 32dfc5d81f..cf8a7fa298 100644
--- a/src/AppInstallerRepositoryCore/RepositorySource.cpp
+++ b/src/AppInstallerRepositoryCore/RepositorySource.cpp
@@ -749,10 +749,17 @@ namespace AppInstaller::Repository
{
auto sourceName = GetDetails().Name;
auto agreementFields = GetAgreementFieldsFromSourceInformation();
- auto agreementsIdentifier = GetInformation().SourceAgreementsIdentifier;
+ auto information = GetInformation();
+
+ if (agreementFields == ImplicitAgreementFieldEnum::None && information.SourceAgreements.empty())
+ {
+ // A source that declares an agreements identifier but no agreements has nothing to accept,
+ // which is the state of a source that removed all of its agreements.
+ return true;
+ }
SourceList sourceList;
- return sourceList.CheckSourceAgreements(sourceName, agreementsIdentifier, agreementFields);
+ return sourceList.CheckSourceAgreements(sourceName, information.SourceAgreementsIdentifier, agreementFields);
}
void Source::SaveAcceptedSourceAgreements() const
diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props
index eaa1863e36..daae70aeb8 100644
--- a/src/Directory.Packages.props
+++ b/src/Directory.Packages.props
@@ -18,6 +18,7 @@
+
diff --git a/src/Microsoft.Management.Deployment/PackageCatalogReference.cpp b/src/Microsoft.Management.Deployment/PackageCatalogReference.cpp
index 44fa2ad604..e1b75de6a9 100644
--- a/src/Microsoft.Management.Deployment/PackageCatalogReference.cpp
+++ b/src/Microsoft.Management.Deployment/PackageCatalogReference.cpp
@@ -393,4 +393,51 @@ namespace winrt::Microsoft::Management::Deployment::implementation
{
return IsInProcCaller() && IsConnectionValidationHandlerEnabledForSource(m_sourceReference);
}
+
+ bool PackageCatalogReference::SourceAgreementsSatisfied()
+ {
+ THROW_IF_FAILED(EnsureComCallerHasCapability(Capability::PackageQuery));
+
+ if (IsComposite())
+ {
+ // Agreement state is per source. Callers should check each non-composite PackageCatalogReference in the composite.
+ throw winrt::hresult_illegal_state_change();
+ }
+
+ return m_sourceReference.CheckSourceAgreements();
+ }
+
+ winrt::Microsoft::Management::Deployment::ImplicitAgreementFields PackageCatalogReference::ImplicitAgreementFields()
+ {
+ THROW_IF_FAILED(EnsureComCallerHasCapability(Capability::PackageQuery));
+
+ if (IsComposite())
+ {
+ // Agreement state is per source. Callers should check each non-composite PackageCatalogReference in the composite.
+ throw winrt::hresult_illegal_state_change();
+ }
+
+ winrt::Microsoft::Management::Deployment::ImplicitAgreementFields result = winrt::Microsoft::Management::Deployment::ImplicitAgreementFields::None;
+ auto fields = m_sourceReference.GetAgreementFieldsFromSourceInformation();
+
+ if (WI_IsFlagSet(fields, ::AppInstaller::Repository::ImplicitAgreementFieldEnum::Market))
+ {
+ result |= winrt::Microsoft::Management::Deployment::ImplicitAgreementFields::Market;
+ }
+
+ return result;
+ }
+
+ void PackageCatalogReference::SaveAcceptedSourceAgreements()
+ {
+ THROW_IF_FAILED(EnsureComCallerHasCapability(Capability::PackageManagement));
+
+ if (IsComposite())
+ {
+ // Agreement state is per source. Callers should save it on each non-composite PackageCatalogReference in the composite.
+ throw winrt::hresult_illegal_state_change();
+ }
+
+ m_sourceReference.SaveAcceptedSourceAgreements();
+ }
}
diff --git a/src/Microsoft.Management.Deployment/PackageCatalogReference.h b/src/Microsoft.Management.Deployment/PackageCatalogReference.h
index 1f25e96e1a..bc29bf3f93 100644
--- a/src/Microsoft.Management.Deployment/PackageCatalogReference.h
+++ b/src/Microsoft.Management.Deployment/PackageCatalogReference.h
@@ -39,6 +39,10 @@ namespace winrt::Microsoft::Management::Deployment::implementation
winrt::Microsoft::Management::Deployment::PackageCatalogConnectionValidationHandler ConnectionValidationHandler();
void ConnectionValidationHandler(winrt::Microsoft::Management::Deployment::PackageCatalogConnectionValidationHandler const& value);
bool IsConnectionValidationHandlerEnabled();
+ // Contract 31
+ bool SourceAgreementsSatisfied();
+ winrt::Microsoft::Management::Deployment::ImplicitAgreementFields ImplicitAgreementFields();
+ void SaveAcceptedSourceAgreements();
#if !defined(INCLUDE_ONLY_INTERFACE_METHODS)
private:
diff --git a/src/Microsoft.Management.Deployment/PackageManager.idl b/src/Microsoft.Management.Deployment/PackageManager.idl
index f5b207741a..b2bed67b22 100644
--- a/src/Microsoft.Management.Deployment/PackageManager.idl
+++ b/src/Microsoft.Management.Deployment/PackageManager.idl
@@ -2,7 +2,7 @@
// Licensed under the MIT License.
namespace Microsoft.Management.Deployment
{
- [contractversion(30)] // For version 1.30
+ [contractversion(31)] // For version 1.31
apicontract WindowsPackageManagerContract{};
/// State of the install
@@ -929,6 +929,17 @@ namespace Microsoft.Management.Deployment
HRESULT ExtendedErrorCode { get; };
};
+ /// Information that a package catalog receives implicitly as part of its use, beyond its explicit
+ /// SourceAgreements. Callers presenting agreements to the user should also disclose these.
+ [contract(Microsoft.Management.Deployment.WindowsPackageManagerContract, 31)]
+ [flags]
+ enum ImplicitAgreementFields
+ {
+ None = 0x0,
+ /// The package catalog receives the market (country or region) of the user.
+ Market = 0x1,
+ };
+
/// A reference to a catalog that callers can try to Connect.
[contract(Microsoft.Management.Deployment.WindowsPackageManagerContract, 1)]
runtimeclass PackageCatalogReference
@@ -1004,6 +1015,30 @@ namespace Microsoft.Management.Deployment
/// BypassCertificatePinningForMicrosoftStore group policy is disabled for the MicrosoftStore catalog).
Boolean IsConnectionValidationHandlerEnabled { get; };
}
+
+ /// The members in this block are not available on a composite package catalog reference and produce an
+ /// illegal state change error if used on one; use them on each non-composite PackageCatalogReference in
+ /// the composite instead. They may need to retrieve information from the package catalog's server, and
+ /// so can fail if it cannot be reached.
+ [contract(Microsoft.Management.Deployment.WindowsPackageManagerContract, 31)]
+ {
+ /// Gets a value indicating whether the user has previously accepted the current agreements of this
+ /// package catalog, as recorded by SaveAcceptedSourceAgreements. Adding a package catalog also records
+ /// acceptance of its agreements at that time.
+ /// Returns true when the package catalog has neither SourceAgreements nor ImplicitAgreementFields.
+ /// Returns false when the package catalog has only ImplicitAgreementFields and they have not
+ /// been accepted, so that the caller can disclose them.
+ Boolean SourceAgreementsSatisfied { get; };
+
+ /// Gets the information that the package catalog receives implicitly as part of its use.
+ ImplicitAgreementFields ImplicitAgreementFields { get; };
+
+ /// Records that the user has accepted the current SourceAgreements and ImplicitAgreementFields of this
+ /// package catalog, so that SourceAgreementsSatisfied returns true until they change.
+ /// Callers must only call this after the user has accepted the agreements.
+ /// This does not change AcceptSourceAgreements.
+ void SaveAcceptedSourceAgreements();
+ }
}
/// Catalogs with PackageCatalogOrigin Predefined
diff --git a/src/WinGetMCPServer/Agreements/AgreementConsent.cs b/src/WinGetMCPServer/Agreements/AgreementConsent.cs
new file mode 100644
index 0000000000..74ed767abf
--- /dev/null
+++ b/src/WinGetMCPServer/Agreements/AgreementConsent.cs
@@ -0,0 +1,215 @@
+// -----------------------------------------------------------------------------
+//
+// Copyright (c) Microsoft Corporation. Licensed under the MIT License.
+//
+// -----------------------------------------------------------------------------
+
+namespace WinGetMCPServer.Agreements
+{
+ using System.Collections.Concurrent;
+ using System.Text;
+ using System.Text.Json;
+ using ModelContextProtocol.Protocol;
+ using ModelContextProtocol.Server;
+ using WinGetMCPServer.Response;
+
+ ///
+ /// The outcome of asking the user to accept a set of agreements.
+ ///
+ internal enum ConsentDecision
+ {
+ ///
+ /// The user accepted the agreements.
+ ///
+ Accepted,
+
+ ///
+ /// The user explicitly declined the agreements.
+ ///
+ Declined,
+
+ ///
+ /// The user dismissed the request without making a choice.
+ ///
+ Dismissed,
+
+ ///
+ /// The user could not be asked directly; the agreements must be returned to the caller to present.
+ ///
+ Pending,
+ }
+
+ ///
+ /// Obtains the user's consent to agreements, using elicitation when the client supports it and
+ /// otherwise falling back to returning the agreements to the caller.
+ ///
+ internal static class AgreementConsent
+ {
+ ///
+ /// The name of the confirmation field in the elicitation form.
+ ///
+ private const string AcceptFieldName = "accept";
+
+ ///
+ /// Agreement text longer than this is truncated in the elicitation message when the agreement also
+ /// has a URL where the full terms can be read, as forms have no way to render long or rich content.
+ ///
+ private const int MaximumAgreementTextLength = 2000;
+
+ ///
+ /// The agreement sets that have been returned to a caller that cannot be elicited from. Without
+ /// elicitation the server cannot observe the user, so this is the strongest available guard
+ /// against a caller claiming acceptance of agreements it was never shown. Tools are constructed
+ /// per call, so this state must be static; it is never persisted.
+ ///
+ private static readonly ConcurrentDictionary PresentedAgreements = new ConcurrentDictionary(StringComparer.Ordinal);
+
+ ///
+ /// Determines whether the client supports form mode elicitation.
+ ///
+ /// The server.
+ /// True if form mode elicitation can be used.
+ public static bool SupportsFormElicitation(McpServer server)
+ {
+ return server.ClientCapabilities?.Elicitation?.Form != null;
+ }
+
+ ///
+ /// Obtains the user's decision on a set of agreements.
+ ///
+ /// The server.
+ /// Text that introduces the agreements to the user.
+ /// The agreements.
+ /// The question asked of the user, used as the label of the confirmation field.
+ /// The decision provided by the caller, if any.
+ /// The cancellation token.
+ /// The decision.
+ public static async Task ObtainAsync(
+ McpServer server,
+ string introduction,
+ IReadOnlyList agreements,
+ string question,
+ bool? acceptAgreements,
+ CancellationToken cancellationToken)
+ {
+ string key = CreatePresentationKey(agreements);
+
+ if (acceptAgreements == false)
+ {
+ // A declined set must be presented again before a later call can accept it.
+ PresentedAgreements.TryRemove(key, out _);
+ return ConsentDecision.Declined;
+ }
+
+ // Ask the user directly whenever possible, even if the caller claims acceptance, so that the
+ // decision is never made by the model on the user's behalf.
+ if (SupportsFormElicitation(server))
+ {
+ try
+ {
+ ElicitResult result = await server.ElicitAsync(CreateRequest(introduction, agreements, question), cancellationToken);
+
+ // The user has now responded directly, so an earlier fallback presentation must not
+ // authorize a later call.
+ PresentedAgreements.TryRemove(key, out _);
+
+ if (result.IsAccepted &&
+ result.Content != null &&
+ result.Content.TryGetValue(AcceptFieldName, out JsonElement value) &&
+ value.ValueKind == JsonValueKind.True)
+ {
+ return ConsentDecision.Accepted;
+ }
+
+ // Submitting the form without checking the box is a refusal.
+ return result.IsAccepted || string.Equals(result.Action, "decline", StringComparison.OrdinalIgnoreCase) ?
+ ConsentDecision.Declined :
+ ConsentDecision.Dismissed;
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception)
+ {
+ // Elicitation failed; fall back to having the caller present the agreements.
+ }
+ }
+
+ // A presentation can be accepted only once, so a later call must present the agreements again.
+ if (acceptAgreements == true && PresentedAgreements.TryRemove(key, out _))
+ {
+ return ConsentDecision.Accepted;
+ }
+
+ PresentedAgreements.TryAdd(key, 0);
+ return ConsentDecision.Pending;
+ }
+
+ ///
+ /// Creates a form mode elicitation request with a single confirmation field. Forms carry only flat
+ /// primitive fields, so the agreements themselves must be conveyed in the message.
+ ///
+ /// Text that introduces the agreements.
+ /// The agreements.
+ /// The question asked of the user, used as the label of the confirmation field.
+ /// The elicitation request.
+ private static ElicitRequestParams CreateRequest(string introduction, IReadOnlyList agreements, string question)
+ {
+ StringBuilder message = new StringBuilder();
+ message.AppendLine(introduction);
+
+ foreach (AgreementInfo agreement in agreements)
+ {
+ message.AppendLine();
+
+ if (!string.IsNullOrWhiteSpace(agreement.Label))
+ {
+ message.AppendLine(agreement.Label);
+ }
+
+ if (!string.IsNullOrWhiteSpace(agreement.Text))
+ {
+ // Long text is truncated only when the full terms remain available at the URL, which
+ // follows; otherwise the user would be accepting terms they could not see.
+ if (agreement.Text.Length > MaximumAgreementTextLength && !string.IsNullOrWhiteSpace(agreement.Url))
+ {
+ message.Append(agreement.Text, 0, MaximumAgreementTextLength);
+ message.AppendLine("\u2026");
+ }
+ else
+ {
+ message.AppendLine(agreement.Text);
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(agreement.Url))
+ {
+ message.AppendLine(agreement.Url);
+ }
+ }
+
+ return new ElicitRequestParams()
+ {
+ Message = message.ToString().TrimEnd(),
+ RequestedSchema = new ElicitRequestParams.RequestSchema()
+ {
+ Properties =
+ {
+ [AcceptFieldName] = new ElicitRequestParams.BooleanSchema()
+ {
+ Title = question,
+ Default = false,
+ },
+ },
+ Required = [AcceptFieldName],
+ },
+ };
+ }
+
+ private static string CreatePresentationKey(IReadOnlyList agreements)
+ {
+ return JsonSerializer.Serialize(agreements, ToolResponse.GetDefaultJsonOptions());
+ }
+ }
+}
diff --git a/src/WinGetMCPServer/Agreements/PackageAgreementConsent.cs b/src/WinGetMCPServer/Agreements/PackageAgreementConsent.cs
new file mode 100644
index 0000000000..5dbfd1389a
--- /dev/null
+++ b/src/WinGetMCPServer/Agreements/PackageAgreementConsent.cs
@@ -0,0 +1,86 @@
+// -----------------------------------------------------------------------------
+//
+// Copyright (c) Microsoft Corporation. Licensed under the MIT License.
+//
+// -----------------------------------------------------------------------------
+
+namespace WinGetMCPServer.Agreements
+{
+ using Microsoft.Management.Deployment;
+ using WinGetMCPServer.Response;
+
+ ///
+ /// Reads the agreements that a package requires to be accepted before installing.
+ ///
+ internal static class PackageAgreementConsent
+ {
+ ///
+ /// Gets the agreements for the version of a package that would be installed.
+ ///
+ /// The package version that would be installed.
+ /// The agreements; empty if there are none.
+ public static List GetAgreements(PackageVersionInfo? packageVersion)
+ {
+ List result = new List();
+
+ if (packageVersion == null)
+ {
+ return result;
+ }
+
+ // Failures propagate: treating unreadable metadata as having no agreements could install a package
+ // without presenting agreements that it requires.
+ // Uses the localization selected by the user's WinGet settings.
+ CatalogPackageMetadata? metadata = packageVersion.GetCatalogPackageMetadata();
+
+ if (metadata?.Agreements == null)
+ {
+ return result;
+ }
+
+ string? source = packageVersion.PackageCatalog?.Info?.Name;
+
+ // Enumerating WinRT collections through IEnumerable fails with the embedded projection, so index instead.
+ var agreements = metadata.Agreements;
+ for (int i = 0; i < agreements.Count; ++i)
+ {
+ PackageAgreement agreement = agreements[i];
+ result.Add(new AgreementInfo()
+ {
+ Kind = AgreementInfo.PackageKind,
+ Source = source,
+ Package = packageVersion.Id,
+ Label = NullIfEmpty(agreement.Label),
+ Text = NullIfEmpty(agreement.Text),
+ Url = NullIfEmpty(agreement.Url),
+ });
+ }
+
+ return result;
+ }
+
+ ///
+ /// Gets the text that introduces the agreements of a package to the user, in the same form as the CLI.
+ ///
+ /// The package version that would be installed.
+ /// The introduction.
+ public static string GetIntroduction(PackageVersionInfo packageVersion)
+ {
+ return $"{LocalizedStrings.Get("ReportIdentityForAgreements")} {packageVersion.DisplayName} [{packageVersion.Id}] {LocalizedStrings.Get("ShowVersion")} {packageVersion.Version}";
+ }
+
+ ///
+ /// Gets the question asked of the user after the agreements of a package.
+ ///
+ /// The question.
+ public static string GetQuestion()
+ {
+ return LocalizedStrings.Get("PackageAgreementsPrompt");
+ }
+
+ private static string? NullIfEmpty(string? value)
+ {
+ return string.IsNullOrEmpty(value) ? null : value;
+ }
+ }
+}
diff --git a/src/WinGetMCPServer/Agreements/SourceAgreementConsent.cs b/src/WinGetMCPServer/Agreements/SourceAgreementConsent.cs
new file mode 100644
index 0000000000..cb5069ce7b
--- /dev/null
+++ b/src/WinGetMCPServer/Agreements/SourceAgreementConsent.cs
@@ -0,0 +1,173 @@
+// -----------------------------------------------------------------------------
+//
+// Copyright (c) Microsoft Corporation. Licensed under the MIT License.
+//
+// -----------------------------------------------------------------------------
+
+namespace WinGetMCPServer.Agreements
+{
+ using System.Collections.Concurrent;
+ using Microsoft.Management.Deployment;
+ using ModelContextProtocol.Server;
+ using WinGetMCPServer.Response;
+
+ ///
+ /// The catalogs to connect after obtaining consent to their source agreements.
+ ///
+ internal sealed class CatalogSelection
+ {
+ ///
+ /// Gets the catalogs that may be used.
+ ///
+ public List Included { get; } = new List();
+
+ ///
+ /// Gets the catalogs that were excluded.
+ ///
+ public ExcludedSources Excluded { get; } = new ExcludedSources();
+
+ ///
+ /// Gets the agreements that must be presented to the user by the caller before continuing.
+ ///
+ public List Pending { get; } = new List();
+ }
+
+ ///
+ /// Obtains consent to the agreements of package sources before they are used.
+ ///
+ internal static class SourceAgreementConsent
+ {
+ ///
+ /// The sources whose agreements the user declined in this server session. These are not asked
+ /// about again unless the caller indicates that the user has changed their mind. Tools are
+ /// constructed per call, so this state must be static; it is never persisted.
+ ///
+ private static readonly ConcurrentDictionary DeclinedSources = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase);
+
+ ///
+ /// Determines which catalogs may be used, obtaining consent to their agreements where required.
+ ///
+ /// The server.
+ /// The individual (non-composite) catalogs to consider.
+ /// The decision provided by the caller, if any.
+ /// The cancellation token.
+ /// The catalogs to use.
+ public static async Task SelectAsync(
+ McpServer server,
+ IEnumerable catalogs,
+ bool? acceptAgreements,
+ CancellationToken cancellationToken)
+ {
+ CatalogSelection selection = new CatalogSelection();
+
+ foreach (PackageCatalogReference catalog in catalogs)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ string name = catalog.Info.Name;
+ List agreements;
+
+ try
+ {
+ if (catalog.SourceAgreementsSatisfied)
+ {
+ selection.Included.Add(catalog);
+ continue;
+ }
+
+ agreements = GetAgreements(catalog, name);
+ }
+ catch (Exception)
+ {
+ // Reading the agreements requires contacting the source. If that fails, the catalog cannot
+ // be used: connecting would retry the request and, should it then succeed, use the source
+ // without the user having seen its agreements.
+ selection.Excluded.Unavailable.Add(name);
+ continue;
+ }
+
+ if (acceptAgreements != true && DeclinedSources.ContainsKey(name))
+ {
+ selection.Excluded.NotAccepted.Add(name);
+ continue;
+ }
+
+ ConsentDecision decision = await AgreementConsent.ObtainAsync(
+ server,
+ LocalizedStrings.Format("SourceAgreementsTitle", name),
+ agreements,
+ LocalizedStrings.Get("SourceAgreementsPrompt"),
+ acceptAgreements,
+ cancellationToken);
+
+ switch (decision)
+ {
+ case ConsentDecision.Accepted:
+ DeclinedSources.TryRemove(name, out _);
+ try
+ {
+ catalog.SaveAcceptedSourceAgreements();
+ }
+ catch (Exception)
+ {
+ // The user has consented, so the catalog can be used; the user will just be
+ // asked again in the future.
+ }
+
+ selection.Included.Add(catalog);
+ break;
+ case ConsentDecision.Declined:
+ DeclinedSources.TryAdd(name, 0);
+ selection.Excluded.NotAccepted.Add(name);
+ break;
+ case ConsentDecision.Dismissed:
+ selection.Excluded.NotAccepted.Add(name);
+ break;
+ case ConsentDecision.Pending:
+ selection.Pending.AddRange(agreements);
+ break;
+ }
+ }
+
+ return selection;
+ }
+
+ private static List GetAgreements(PackageCatalogReference catalog, string name)
+ {
+ List result = new List();
+
+ // Enumerating WinRT collections through IEnumerable fails with the embedded projection, so index instead.
+ var sourceAgreements = catalog.SourceAgreements;
+ for (int i = 0; i < sourceAgreements.Count; ++i)
+ {
+ SourceAgreement agreement = sourceAgreements[i];
+ result.Add(new AgreementInfo()
+ {
+ Kind = AgreementInfo.SourceKind,
+ Source = name,
+ Label = NullIfEmpty(agreement.Label),
+ Text = NullIfEmpty(agreement.Text),
+ Url = NullIfEmpty(agreement.Url),
+ });
+ }
+
+ if ((catalog.ImplicitAgreementFields & ImplicitAgreementFields.Market) != 0)
+ {
+ // The CLI presents this disclosure without a label, so none is given here either.
+ result.Add(new AgreementInfo()
+ {
+ Kind = AgreementInfo.SourceKind,
+ Source = name,
+ Text = LocalizedStrings.Get("SourceAgreementsMarketMessage"),
+ });
+ }
+
+ return result;
+ }
+
+ private static string? NullIfEmpty(string? value)
+ {
+ return string.IsNullOrEmpty(value) ? null : value;
+ }
+ }
+}
diff --git a/src/WinGetMCPServer/LocalizedStrings.cs b/src/WinGetMCPServer/LocalizedStrings.cs
new file mode 100644
index 0000000000..36e7e567dc
--- /dev/null
+++ b/src/WinGetMCPServer/LocalizedStrings.cs
@@ -0,0 +1,76 @@
+// -----------------------------------------------------------------------------
+//
+// Copyright (c) Microsoft Corporation. Licensed under the MIT License.
+//
+// -----------------------------------------------------------------------------
+
+namespace WinGetMCPServer
+{
+ using Windows.ApplicationModel.Resources;
+
+ ///
+ /// Provides localized strings from the WinGet package resources, shared with the CLI.
+ ///
+ internal static class LocalizedStrings
+ {
+ private static readonly Lazy Loader = new Lazy(CreateLoader);
+
+ ///
+ /// Gets a localized string. As in the CLI, the resource key is returned when the resources are unavailable.
+ ///
+ /// The resource key.
+ /// The localized string.
+ public static string Get(string key)
+ {
+ try
+ {
+ string? value = Loader.Value?.GetString(key);
+ if (!string.IsNullOrEmpty(value))
+ {
+ return value;
+ }
+ }
+ catch (Exception)
+ {
+ }
+
+ return key;
+ }
+
+ ///
+ /// Gets a localized format string and formats it with the provided arguments.
+ ///
+ /// The resource key.
+ /// The format arguments.
+ /// The formatted localized string.
+ public static string Format(string key, params object?[] args)
+ {
+ string format = Get(key);
+
+ try
+ {
+ return string.Format(format, args);
+ }
+ catch (FormatException)
+ {
+ return format;
+ }
+ }
+
+ private static ResourceLoader? CreateLoader()
+ {
+ try
+ {
+ // Mirrors AppInstallerSharedLib\Resources.cpp: the default constructor throws a catchable
+ // error when resources.pri is not found, whereas GetForViewIndependentUse can fail in a way
+ // that cannot be caught. Constructing the default loader first guards against that.
+ _ = new ResourceLoader();
+ return ResourceLoader.GetForViewIndependentUse("winget");
+ }
+ catch (Exception)
+ {
+ return null;
+ }
+ }
+ }
+}
diff --git a/src/WinGetMCPServer/Program.cs b/src/WinGetMCPServer/Program.cs
index 9f2f418832..77b5c6cb7d 100644
--- a/src/WinGetMCPServer/Program.cs
+++ b/src/WinGetMCPServer/Program.cs
@@ -37,6 +37,13 @@ pass that identifier to install-winget-package. Installing from a guessed identi
Only install or upgrade packages the user has actually asked for, and report the status it
returns rather than assuming that it succeeded.
+ Package sources and packages may require the user to accept agreements. When the client
+ supports elicitation the server asks the user directly. Otherwise a tool returns the
+ agreements instead of its normal result; show them to the user in full and relay only the
+ user's own decision through the parameter that result names. Never accept agreements on the
+ user's behalf, including when the user has asked for something to be installed. If a result
+ notes that sources were not used, mention that to the user, since results may be incomplete.
+
Tool results are JSON, provided both as text and as structured content.
""";
diff --git a/src/WinGetMCPServer/Response/AgreementInfo.cs b/src/WinGetMCPServer/Response/AgreementInfo.cs
new file mode 100644
index 0000000000..9088546d71
--- /dev/null
+++ b/src/WinGetMCPServer/Response/AgreementInfo.cs
@@ -0,0 +1,45 @@
+// -----------------------------------------------------------------------------
+//
+// Copyright (c) Microsoft Corporation. Licensed under the MIT License.
+//
+// -----------------------------------------------------------------------------
+
+namespace WinGetMCPServer.Response
+{
+ ///
+ /// An agreement that must be presented to the user.
+ ///
+ internal class AgreementInfo
+ {
+ ///
+ /// The agreement is required by a package source.
+ ///
+ public const string SourceKind = "source";
+
+ ///
+ /// The agreement is required by a package.
+ ///
+ public const string PackageKind = "package";
+
+ ///
+ /// Gets whether the agreement is required by a source or a package.
+ ///
+ public required string Kind { get; init; }
+
+ ///
+ /// Gets the name of the source that requires the agreement, or that provides the package.
+ ///
+ public string? Source { get; init; }
+
+ ///
+ /// Gets the identifier of the package that requires the agreement.
+ ///
+ public string? Package { get; init; }
+
+ public string? Label { get; init; }
+
+ public string? Text { get; init; }
+
+ public string? Url { get; init; }
+ }
+}
diff --git a/src/WinGetMCPServer/Response/AgreementResponse.cs b/src/WinGetMCPServer/Response/AgreementResponse.cs
new file mode 100644
index 0000000000..82f775a93a
--- /dev/null
+++ b/src/WinGetMCPServer/Response/AgreementResponse.cs
@@ -0,0 +1,77 @@
+// -----------------------------------------------------------------------------
+//
+// Copyright (c) Microsoft Corporation. Licensed under the MIT License.
+//
+// -----------------------------------------------------------------------------
+
+namespace WinGetMCPServer.Response
+{
+ using ModelContextProtocol.Protocol;
+
+ ///
+ /// Contains reusable responses for agreements.
+ ///
+ internal static class AgreementResponse
+ {
+ ///
+ /// Creates a response that asks the caller to present agreements to the user.
+ ///
+ /// The agreements to present.
+ /// The name of the tool parameter that conveys the user's decision.
+ ///
+ /// Whether declining allows the operation to continue without the things that require the agreements.
+ ///
+ /// The response.
+ public static CallToolResult ForAgreementsRequired(List agreements, string parameterName, bool declineContinues)
+ {
+ string onDecline = declineContinues ?
+ $"If the user declines, call this tool again with the same arguments and {parameterName} set to false to continue without the sources that require them." :
+ "If the user declines, do not call this tool again for this request.";
+
+ AgreementsRequiredResult result = new()
+ {
+ Message = "The user must accept these agreements before this operation can continue, and has not yet been shown them. " +
+ "Present every agreement to the user exactly as provided, including its label, text, and url, and ask whether they accept. " +
+ "Do not accept on the user's behalf. " +
+ $"Only if the user explicitly accepts, call this tool again with the same arguments and {parameterName} set to true. " +
+ onDecline,
+ Agreements = agreements,
+ };
+
+ return ToolResponse.FromObject(result);
+ }
+
+ ///
+ /// Creates a response for when every requested source was excluded.
+ ///
+ /// The sources that were excluded.
+ /// The response.
+ public static CallToolResult ForAllSourcesExcluded(ExcludedSources excludedSources)
+ {
+ return ToolResponse.WithExcludedSources(
+ new CallToolResult()
+ {
+ IsError = true,
+ Content = [new TextContentBlock() { Text = "No package sources are available; the user has not accepted the agreements of the requested sources, or their agreements could not be retrieved." }],
+ },
+ excludedSources);
+ }
+
+ ///
+ /// Creates a response for when the user did not accept the agreements of a package.
+ ///
+ /// The identifier of the package.
+ /// The agreements that were not accepted.
+ /// The response.
+ public static CallToolResult ForPackageAgreementsNotAccepted(string identifier, List agreements)
+ {
+ AgreementsRequiredResult result = new()
+ {
+ Message = $"The package was not installed because the user did not accept its agreements: {identifier}",
+ Agreements = agreements,
+ };
+
+ return ToolResponse.FromObject(result, isError: true);
+ }
+ }
+}
diff --git a/src/WinGetMCPServer/Response/AgreementsRequiredResult.cs b/src/WinGetMCPServer/Response/AgreementsRequiredResult.cs
new file mode 100644
index 0000000000..a92a316137
--- /dev/null
+++ b/src/WinGetMCPServer/Response/AgreementsRequiredResult.cs
@@ -0,0 +1,18 @@
+// -----------------------------------------------------------------------------
+//
+// Copyright (c) Microsoft Corporation. Licensed under the MIT License.
+//
+// -----------------------------------------------------------------------------
+
+namespace WinGetMCPServer.Response
+{
+ ///
+ /// Contains agreements that the user must accept before the operation can continue.
+ ///
+ internal class AgreementsRequiredResult
+ {
+ public required string Message { get; init; }
+
+ public List Agreements { get; init; } = new List();
+ }
+}
diff --git a/src/WinGetMCPServer/Response/ExcludedSources.cs b/src/WinGetMCPServer/Response/ExcludedSources.cs
new file mode 100644
index 0000000000..a7ca7d80b2
--- /dev/null
+++ b/src/WinGetMCPServer/Response/ExcludedSources.cs
@@ -0,0 +1,40 @@
+// -----------------------------------------------------------------------------
+//
+// Copyright (c) Microsoft Corporation. Licensed under the MIT License.
+//
+// -----------------------------------------------------------------------------
+
+namespace WinGetMCPServer.Response
+{
+ ///
+ /// The sources that were not used for a request.
+ ///
+ internal sealed class ExcludedSources
+ {
+ ///
+ /// Gets the names of the sources whose agreements the user has not accepted.
+ ///
+ public List NotAccepted { get; } = new List();
+
+ ///
+ /// Gets the names of the sources whose agreement state could not be determined. These are not used,
+ /// as doing so could bypass the user's consent once the source becomes reachable.
+ ///
+ public List Unavailable { get; } = new List();
+
+ ///
+ /// Gets a value indicating whether no sources were excluded.
+ ///
+ public bool IsEmpty => NotAccepted.Count == 0 && Unavailable.Count == 0;
+
+ ///
+ /// Adds the sources excluded by another request.
+ ///
+ /// The other excluded sources.
+ public void AddRange(ExcludedSources other)
+ {
+ NotAccepted.AddRange(other.NotAccepted);
+ Unavailable.AddRange(other.Unavailable);
+ }
+ }
+}
diff --git a/src/WinGetMCPServer/Response/PackageResponse.cs b/src/WinGetMCPServer/Response/PackageResponse.cs
index 6888f8fe88..9cbf4f74d6 100644
--- a/src/WinGetMCPServer/Response/PackageResponse.cs
+++ b/src/WinGetMCPServer/Response/PackageResponse.cs
@@ -156,7 +156,7 @@ public static CallToolResult ForUpgradeOperation(InstallResult installResult, Fi
result.Message = "No applicable upgrade was available for this system";
break;
case InstallResultStatus.PackageAgreementsNotAccepted:
- result.Message = "The package requires accepting agreements; please upgrade manually";
+ result.Message = "The package requires accepting agreements that could not be presented to the user; please upgrade manually";
break;
default:
result.Message = "Unknown upgrade status";
@@ -226,7 +226,7 @@ public static CallToolResult ForInstallOperation(InstallResult installResult, Fi
result.Message = "No applicable upgrade was available for this system";
break;
case InstallResultStatus.PackageAgreementsNotAccepted:
- result.Message = "The package requires accepting agreements; please install manually";
+ result.Message = "The package requires accepting agreements that could not be presented to the user; please install manually";
break;
default:
result.Message = "Unknown install status";
diff --git a/src/WinGetMCPServer/Response/ToolResponse.cs b/src/WinGetMCPServer/Response/ToolResponse.cs
index 56ece8f9c1..13b38662bf 100644
--- a/src/WinGetMCPServer/Response/ToolResponse.cs
+++ b/src/WinGetMCPServer/Response/ToolResponse.cs
@@ -87,6 +87,57 @@ public static JsonSerializerOptions GetDefaultJsonOptions()
return DefaultJsonOptions;
}
+ ///
+ /// Notes in a response that some sources were not used.
+ ///
+ /// The response to add the note to.
+ /// The sources that were excluded.
+ /// The response provided in .
+ public static CallToolResult WithExcludedSources(CallToolResult response, ExcludedSources excludedSources)
+ {
+ if (excludedSources.IsEmpty)
+ {
+ return response;
+ }
+
+ // Separate blocks are used so that the existing JSON block is unchanged for callers that parse it.
+ if (excludedSources.NotAccepted.Count > 0)
+ {
+ response.Content.Add(new TextContentBlock()
+ {
+ Text = $"These sources were not used because the user has not accepted their agreements: {string.Join(", ", excludedSources.NotAccepted)}. " +
+ "If the user wants to use them, call this tool again with acceptSourceAgreements set to true to ask the user again.",
+ });
+ }
+
+ if (excludedSources.Unavailable.Count > 0)
+ {
+ response.Content.Add(new TextContentBlock()
+ {
+ Text = $"These sources were not used because their agreements could not be retrieved; they may be temporarily unreachable: {string.Join(", ", excludedSources.Unavailable)}.",
+ });
+ }
+
+ if (response.StructuredContent is JsonElement structuredContent && structuredContent.ValueKind == JsonValueKind.Object)
+ {
+ JsonObject updated = JsonNode.Parse(structuredContent.GetRawText())!.AsObject();
+
+ if (excludedSources.NotAccepted.Count > 0)
+ {
+ updated["excludedSources"] = JsonSerializer.SerializeToNode(excludedSources.NotAccepted, DefaultJsonOptions);
+ }
+
+ if (excludedSources.Unavailable.Count > 0)
+ {
+ updated["unavailableSources"] = JsonSerializer.SerializeToNode(excludedSources.Unavailable, DefaultJsonOptions);
+ }
+
+ response.StructuredContent = JsonSerializer.SerializeToElement(updated);
+ }
+
+ return response;
+ }
+
private static CallToolResult Create(object value, bool isError, JsonSerializerOptions jsonSerializerOptions, string? collectionPropertyName)
{
JsonElement element = JsonSerializer.SerializeToElement(value, jsonSerializerOptions);
diff --git a/src/WinGetMCPServer/WinGetMCPServer.csproj b/src/WinGetMCPServer/WinGetMCPServer.csproj
index 09c8396663..0b50cbd19e 100644
--- a/src/WinGetMCPServer/WinGetMCPServer.csproj
+++ b/src/WinGetMCPServer/WinGetMCPServer.csproj
@@ -43,6 +43,8 @@
Microsoft.Management.Deployment;
+ Windows.ApplicationModel.Resources.ResourceLoade;
+ Windows.ApplicationModel.Resources.IResourceLoade;
Windows.Data.Text.TextSegmen;
Windows.Devices.Geolocation;
Windows.Foundation;
@@ -59,6 +61,8 @@
Windows.System.ProcessorArchitectur;
Windows.System.Use;
Windows.System.IUse;
+ Windows.UI.UIContex;
+ Windows.UI.IUIContex;
Windows.Foundation.PropertyType;
diff --git a/src/WinGetMCPServer/WingetPackageTools.cs b/src/WinGetMCPServer/WingetPackageTools.cs
index 93fa1d5497..702271e3a6 100644
--- a/src/WinGetMCPServer/WingetPackageTools.cs
+++ b/src/WinGetMCPServer/WingetPackageTools.cs
@@ -12,6 +12,7 @@ namespace WinGetMCPServer
using ModelContextProtocol.Server;
using ModelContextProtocol;
using Windows.Foundation;
+ using WinGetMCPServer.Agreements;
using WinGetMCPServer.Extensions;
using WinGetMCPServer.Response;
using WinGetMCPServer.Exceptions;
@@ -22,6 +23,16 @@ namespace WinGetMCPServer
[McpServerToolType]
internal class WingetPackageTools
{
+ private const string AgreementsDescription = "the user is asked to accept them directly when the client supports it. Otherwise, the agreements are returned instead of the normal result and must be presented to the user, and the tool called again with the parameter named in that result set to the user's decision. Never accept agreements on the user's behalf.";
+
+ private const string AcceptSourceAgreementsParameterName = "acceptSourceAgreements";
+
+ private const string AcceptPackageAgreementsParameterName = "acceptPackageAgreements";
+
+ private const string AcceptSourceAgreementsParameterDescription = "Only set this to relay the user's explicit decision on package source agreements returned by a previous call: true if the user accepted them, false if the user declined them. Never set this without asking the user. Setting true also asks the user again about sources they declined earlier.";
+
+ private const string AcceptPackageAgreementsParameterDescription = "Only set this to relay the user's explicit decision on package agreements returned by a previous call: true if the user accepted them, false if the user declined them. Never set this without asking the user.";
+
private PackageManager packageManager;
public WingetPackageTools()
@@ -34,10 +45,13 @@ public WingetPackageTools()
Title = "Find WinGet Packages",
ReadOnly = true,
OpenWorld = false)]
- [Description("Find installed and available packages using WinGet. To list all installed packages that have available upgrades (equivalent to 'winget upgrade'), call with upgradeable=true and no query. To filter upgradeable packages by name, call with upgradeable=true and a query. To search for packages to install, call with upgradeable=false and a required query.")]
- public CallToolResult FindPackages(
+ [Description("Find installed and available packages using WinGet. To list all installed packages that have available upgrades (equivalent to 'winget upgrade'), call with upgradeable=true and no query. To filter upgradeable packages by name, call with upgradeable=true and a query. To search for packages to install, call with upgradeable=false and a required query. Package sources may require the user to accept agreements before they are used; " + AgreementsDescription)]
+ public async Task FindPackages(
+ McpServer server,
+ CancellationToken cancellationToken,
[Description("Find packages identified by this value. Required when upgradeable is false; optionally filters results when upgradeable is true.")] string? query = null,
- [Description("When true, only return installed packages that have available upgrades")] bool upgradeable = false)
+ [Description("When true, only return installed packages that have available upgrades")] bool upgradeable = false,
+ [Description(AcceptSourceAgreementsParameterDescription)] bool? acceptSourceAgreements = null)
{
try
{
@@ -55,9 +69,13 @@ public CallToolResult FindPackages(
// Use LocalCatalogs when listing upgrades to enumerate only installed packages,
// consistent with `winget upgrade`. Remote catalogs are still included in the
// composite so IsUpdateAvailable remains accurate.
- var catalog = ConnectCatalog(searchBehavior: upgradeable
- ? CompositeSearchBehavior.LocalCatalogs
- : CompositeSearchBehavior.AllCatalogs);
+ var (catalog, excludedSources) = await ConnectCatalogAsync(
+ server,
+ acceptSourceAgreements,
+ cancellationToken,
+ searchBehavior: upgradeable
+ ? CompositeSearchBehavior.LocalCatalogs
+ : CompositeSearchBehavior.AllCatalogs);
FindPackagesResult findResult;
if (string.IsNullOrEmpty(query))
@@ -80,7 +98,7 @@ public CallToolResult FindPackages(
if (findResult.Status != FindPackagesResultStatus.Ok)
{
- return PackageResponse.ForFindError(findResult);
+ return ToolResponse.WithExcludedSources(PackageResponse.ForFindError(findResult), excludedSources);
}
List contents = new List();
@@ -100,7 +118,7 @@ public CallToolResult FindPackages(
contents.AddPackages(findResult);
}
- return ToolResponse.FromCollection(contents, "packages");
+ return ToolResponse.WithExcludedSources(ToolResponse.FromCollection(contents, "packages"), excludedSources);
}
catch (ToolResponseException e)
{
@@ -115,103 +133,178 @@ public CallToolResult FindPackages(
Destructive = true,
Idempotent = false,
OpenWorld = false)]
- [Description("Install or upgrade a package using WinGet. When upgradeOnly is true, only upgrades an already-installed package and returns an error if it is not installed. When upgradeOnly is false (default), installs the package if not present or upgrades it if already installed.")]
+ [Description("Install or upgrade a package using WinGet. When upgradeOnly is true, only upgrades an already-installed package and returns an error if it is not installed. When upgradeOnly is false (default), installs the package if not present or upgrades it if already installed. Package sources and packages may require the user to accept agreements; " + AgreementsDescription)]
public async Task InstallPackage(
+ McpServer server,
[Description("The identifier of the WinGet package")] string identifier,
IProgress progress,
CancellationToken cancellationToken,
[Description("The source containing the package")] string? source = null,
- [Description("When true, only upgrade an already-installed package; returns an error if the package is not installed")] bool upgradeOnly = false)
+ [Description("When true, only upgrade an already-installed package; returns an error if the package is not installed")] bool upgradeOnly = false,
+ [Description(AcceptSourceAgreementsParameterDescription)] bool? acceptSourceAgreements = null,
+ [Description(AcceptPackageAgreementsParameterDescription)] bool? acceptPackageAgreements = null)
{
+ ExcludedSources excludedSources = new ExcludedSources();
+ CallToolResult result;
+
try
{
- ToolResponse.CheckGroupPolicy();
+ result = await InstallPackageCore(server, identifier, progress, source, upgradeOnly, acceptSourceAgreements, acceptPackageAgreements, excludedSources, cancellationToken);
+ }
+ catch (ToolResponseException e)
+ {
+ result = e.Response;
+ }
- var packageCatalog = ConnectCatalog(source);
+ return ToolResponse.WithExcludedSources(result, excludedSources);
+ }
- if (cancellationToken.IsCancellationRequested)
- {
- return PackageResponse.ForCancelBeforeSystemChange();
- }
+ private async Task InstallPackageCore(
+ McpServer server,
+ string identifier,
+ IProgress progress,
+ string? source,
+ bool upgradeOnly,
+ bool? acceptSourceAgreements,
+ bool? acceptPackageAgreements,
+ ExcludedSources excludedSources,
+ CancellationToken cancellationToken)
+ {
+ ToolResponse.CheckGroupPolicy();
- // First attempt a more exact match
- var findResult = FindForIdentifier(packageCatalog, identifier, expandedFields: false);
+ PackageCatalog packageCatalog;
+ ExcludedSources excludedCatalogs;
- if (cancellationToken.IsCancellationRequested)
- {
- return PackageResponse.ForCancelBeforeSystemChange();
- }
+ try
+ {
+ (packageCatalog, excludedCatalogs) = await ConnectCatalogAsync(server, acceptSourceAgreements, cancellationToken, source);
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ return PackageResponse.ForCancelBeforeSystemChange();
+ }
- // If nothing is found, expand to a looser search
- if ((findResult.Matches?.Count ?? 0) == 0)
- {
- findResult = FindForIdentifier(packageCatalog, identifier, expandedFields: true);
- }
+ excludedSources.AddRange(excludedCatalogs);
- if (findResult.Status != FindPackagesResultStatus.Ok)
- {
- return PackageResponse.ForFindError(findResult);
- }
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return PackageResponse.ForCancelBeforeSystemChange();
+ }
- if (findResult.Matches?.Count == 0)
- {
- return PackageResponse.ForEmptyFind(identifier, source);
- }
- else if (findResult.Matches?.Count > 1)
- {
- return PackageResponse.ForMultiFind(identifier, source, findResult);
- }
+ // First attempt a more exact match
+ var findResult = FindForIdentifier(packageCatalog, identifier, expandedFields: false);
- CatalogPackage catalogPackage = findResult.Matches![0].CatalogPackage;
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return PackageResponse.ForCancelBeforeSystemChange();
+ }
- if (upgradeOnly && catalogPackage.InstalledVersion == null)
- {
- return PackageResponse.ForNotInstalled(identifier, source);
- }
+ // If nothing is found, expand to a looser search
+ if ((findResult.Matches?.Count ?? 0) == 0)
+ {
+ findResult = FindForIdentifier(packageCatalog, identifier, expandedFields: true);
+ }
- InstallOptions options = new InstallOptions();
- IAsyncOperationWithProgress? operation = null;
+ if (findResult.Status != FindPackagesResultStatus.Ok)
+ {
+ return PackageResponse.ForFindError(findResult);
+ }
- if (cancellationToken.IsCancellationRequested)
- {
- return PackageResponse.ForCancelBeforeSystemChange();
- }
+ if (findResult.Matches?.Count == 0)
+ {
+ return PackageResponse.ForEmptyFind(identifier, source);
+ }
+ else if (findResult.Matches?.Count > 1)
+ {
+ return PackageResponse.ForMultiFind(identifier, source, findResult);
+ }
+
+ CatalogPackage catalogPackage = findResult.Matches![0].CatalogPackage;
+
+ if (upgradeOnly && catalogPackage.InstalledVersion == null)
+ {
+ return PackageResponse.ForNotInstalled(identifier, source);
+ }
+
+ InstallOptions options = new InstallOptions();
+
+ // Package agreements are presented on every install, as the CLI does; unlike source agreements,
+ // acceptance is not persisted.
+ PackageVersionInfo? installVersion = catalogPackage.DefaultInstallVersion;
+ List packageAgreements = PackageAgreementConsent.GetAgreements(installVersion);
+ if (packageAgreements.Count > 0)
+ {
+ ConsentDecision decision;
- if (catalogPackage.InstalledVersion == null)
+ try
{
- operation = packageManager.InstallPackageAsync(catalogPackage, options);
+ decision = await AgreementConsent.ObtainAsync(
+ server,
+ PackageAgreementConsent.GetIntroduction(installVersion!),
+ packageAgreements,
+ PackageAgreementConsent.GetQuestion(),
+ acceptPackageAgreements,
+ cancellationToken);
}
- else
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
- operation = packageManager.UpgradePackageAsync(catalogPackage, options);
+ return PackageResponse.ForCancelBeforeSystemChange();
}
- operation.Progress = (asyncInfo, progressInfo) => progress.Report(CreateInstallProgressNotification(ref progressInfo));
- using CancellationTokenRegistration registration = cancellationToken.Register(() => operation.Cancel());
-
- var installResult = await operation;
- findResult = null;
-
- if (installResult.Status == InstallResultStatus.Ok)
+ switch (decision)
{
- // Send a completed progress entry in the event that async progress forwarding didn't
- progress.Report(CreateInstallProgressNotification(PackageInstallProgressState.Finished, 1.0, 1.0));
- findResult = ReFindForPackage(catalogPackage.DefaultInstallVersion);
+ case ConsentDecision.Accepted:
+ options.AcceptPackageAgreements = true;
+ break;
+ case ConsentDecision.Pending:
+ return AgreementResponse.ForAgreementsRequired(packageAgreements, AcceptPackageAgreementsParameterName, declineContinues: false);
+ default:
+ return AgreementResponse.ForPackageAgreementsNotAccepted(identifier, packageAgreements);
}
+ }
- return upgradeOnly
- ? PackageResponse.ForUpgradeOperation(installResult, findResult)
- : PackageResponse.ForInstallOperation(installResult, findResult);
+ IAsyncOperationWithProgress? operation = null;
+
+ if (cancellationToken.IsCancellationRequested)
+ {
+ return PackageResponse.ForCancelBeforeSystemChange();
}
- catch (ToolResponseException e)
+
+ if (catalogPackage.InstalledVersion == null)
{
- return e.Response;
+ operation = packageManager.InstallPackageAsync(catalogPackage, options);
+ }
+ else
+ {
+ operation = packageManager.UpgradePackageAsync(catalogPackage, options);
+ }
+
+ operation.Progress = (asyncInfo, progressInfo) => progress.Report(CreateInstallProgressNotification(ref progressInfo));
+ using CancellationTokenRegistration registration = cancellationToken.Register(() => operation.Cancel());
+
+ var installResult = await operation;
+ findResult = null;
+
+ if (installResult.Status == InstallResultStatus.Ok)
+ {
+ // Send a completed progress entry in the event that async progress forwarding didn't
+ progress.Report(CreateInstallProgressNotification(PackageInstallProgressState.Finished, 1.0, 1.0));
+ findResult = ReFindForPackage(catalogPackage.DefaultInstallVersion);
}
+
+ return upgradeOnly
+ ? PackageResponse.ForUpgradeOperation(installResult, findResult)
+ : PackageResponse.ForInstallOperation(installResult, findResult);
}
- private ConnectResult ConnectCatalogWithResult(string? catalog = null, CompositeSearchBehavior searchBehavior = CompositeSearchBehavior.AllCatalogs)
+ ///
+ /// Gets the individual catalogs to use for a request.
+ ///
+ /// The name of the catalog requested, or null for all default catalogs.
+ /// The catalogs.
+ private List GetCatalogReferences(string? catalog)
{
- CreateCompositePackageCatalogOptions createCompositePackageCatalogOptions = new CreateCompositePackageCatalogOptions();
+ List result = new List();
var catalogs = packageManager.GetPackageCatalogs();
for (int i = 0; i < catalogs.Count; ++i)
@@ -220,23 +313,60 @@ private ConnectResult ConnectCatalogWithResult(string? catalog = null, Composite
if ((string.IsNullOrEmpty(catalog) && !catalogRef.Info.Explicit)
|| catalogRef?.Info.Name == catalog)
{
- createCompositePackageCatalogOptions.Catalogs.Add(catalogRef);
+ result.Add(catalogRef!);
}
}
+
+ return result;
+ }
+
+ private ConnectResult ConnectCatalogWithResult(IEnumerable catalogs, CompositeSearchBehavior searchBehavior = CompositeSearchBehavior.AllCatalogs)
+ {
+ CreateCompositePackageCatalogOptions createCompositePackageCatalogOptions = new CreateCompositePackageCatalogOptions();
+
+ foreach (var catalogRef in catalogs)
+ {
+ createCompositePackageCatalogOptions.Catalogs.Add(catalogRef);
+ }
+
createCompositePackageCatalogOptions.CompositeSearchBehavior = searchBehavior;
var compositeRef = packageManager.CreateCompositePackageCatalog(createCompositePackageCatalogOptions);
return compositeRef.Connect();
}
- private PackageCatalog ConnectCatalog(string? catalog = null, CompositeSearchBehavior searchBehavior = CompositeSearchBehavior.AllCatalogs)
+ ///
+ /// Connects to the requested catalogs, first obtaining consent to any source agreements.
+ ///
+ /// The connected catalog and any catalogs that were excluded.
+ private async Task<(PackageCatalog Catalog, ExcludedSources ExcludedSources)> ConnectCatalogAsync(
+ McpServer server,
+ bool? acceptSourceAgreements,
+ CancellationToken cancellationToken,
+ string? catalog = null,
+ CompositeSearchBehavior searchBehavior = CompositeSearchBehavior.AllCatalogs)
{
- var result = ConnectCatalogWithResult(catalog, searchBehavior);
+ CatalogSelection selection = await SourceAgreementConsent.SelectAsync(server, GetCatalogReferences(catalog), acceptSourceAgreements, cancellationToken);
+
+ if (selection.Pending.Count > 0)
+ {
+ throw new ToolResponseException(ToolResponse.WithExcludedSources(
+ AgreementResponse.ForAgreementsRequired(selection.Pending, AcceptSourceAgreementsParameterName, declineContinues: true),
+ selection.Excluded));
+ }
+
+ if (selection.Included.Count == 0 && !selection.Excluded.IsEmpty)
+ {
+ throw new ToolResponseException(AgreementResponse.ForAllSourcesExcluded(selection.Excluded));
+ }
+
+ var result = ConnectCatalogWithResult(selection.Included, searchBehavior);
if (result.Status != ConnectResultStatus.Ok)
{
- throw new ToolResponseException(PackageResponse.ForConnectError(result));
+ throw new ToolResponseException(ToolResponse.WithExcludedSources(PackageResponse.ForConnectError(result), selection.Excluded));
}
- return result.PackageCatalog;
+
+ return (result.PackageCatalog, selection.Excluded);
}
private FindPackagesResult FindForQuery(PackageCatalog catalog, string query, bool fullStringMatch)
@@ -273,7 +403,8 @@ private FindPackagesResult FindAllPackages(PackageCatalog catalog)
private FindPackagesResult? ReFindForPackage(PackageVersionInfo packageVersionInfo)
{
- var connectResult = ConnectCatalogWithResult(packageVersionInfo.PackageCatalog.Info.Id);
+ // Consent is not obtained here, as the package was just installed from this source.
+ var connectResult = ConnectCatalogWithResult(GetCatalogReferences(packageVersionInfo.PackageCatalog.Info.Id));
if (connectResult.Status != ConnectResultStatus.Ok)
{