From d3acebb29048aa4b4fe0f5103e3368d0f66d9035 Mon Sep 17 00:00:00 2001 From: yayayouyou Date: Fri, 10 Jul 2026 21:47:27 +0800 Subject: [PATCH 1/2] Add round-trip integration tests for MCP Apps _meta.ui serialization Covers the gap called out in #1608: tools registered with [McpAppUi] and processed by WithMcpApps() are listed through McpClient.ListToolsAsync() via ClientServerTestBase, verifying the structured _meta.ui object end-to-end across the transport rather than only on the option pipeline: - resourceUri round-trips for an attributed tool - visibility round-trips for a restricted tool (and is omitted when not restricted) - tools without the attribute stay free of ui metadata Used AI assistance; reviewed and tested by me (3/3 pass on net10.0 locally; net8.0/net9.0 compile but need runtimes not installed locally). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Server/McpAppsIntegrationTests.cs | 96 +++++++++++++++++++ 1 file changed, 96 insertions(+) create mode 100644 tests/ModelContextProtocol.Tests/Server/McpAppsIntegrationTests.cs diff --git a/tests/ModelContextProtocol.Tests/Server/McpAppsIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Server/McpAppsIntegrationTests.cs new file mode 100644 index 000000000..5d95979f0 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpAppsIntegrationTests.cs @@ -0,0 +1,96 @@ +#pragma warning disable MCPEXP003 + +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Apps; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Round-trip integration tests for the MCP Apps _meta.ui metadata: tools registered +/// with and processed by WithMcpApps() are listed through +/// and the structured _meta.ui object is verified +/// after serializing across the client-server transport. +/// +public class McpAppsIntegrationTests : ClientServerTestBase +{ + public McpAppsIntegrationTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder + .WithTools() + .WithMcpApps(); + } + + [Fact] + public async Task ListToolsAsync_RoundTripsMetaUi_ForToolWithAppUiAttribute() + { + await using McpClient client = await CreateMcpClientForServer(); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + var weatherTool = Assert.Single(tools, t => t.Name == "weather_tool"); + + JsonObject? uiNode = weatherTool.ProtocolTool.Meta?["ui"]?.AsObject(); + Assert.NotNull(uiNode); + Assert.Equal("ui://weather/view.html", uiNode["resourceUri"]?.GetValue()); + + // Visibility was not restricted, so the attribute must not add the property. + Assert.Null(uiNode["visibility"]); + } + + [Fact] + public async Task ListToolsAsync_RoundTripsMetaUiVisibility_ForRestrictedTool() + { + await using McpClient client = await CreateMcpClientForServer(); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + var modelOnlyTool = Assert.Single(tools, t => t.Name == "model_only_tool"); + + JsonObject? uiNode = modelOnlyTool.ProtocolTool.Meta?["ui"]?.AsObject(); + Assert.NotNull(uiNode); + Assert.Equal("ui://model-only/view.html", uiNode["resourceUri"]?.GetValue()); + + JsonArray? visibility = uiNode["visibility"]?.AsArray(); + Assert.NotNull(visibility); + JsonNode? single = Assert.Single(visibility); + Assert.Equal(McpUiToolVisibility.Model, single?.GetValue()); + } + + [Fact] + public async Task ListToolsAsync_HasNoUiMeta_ForToolWithoutAppUiAttribute() + { + await using McpClient client = await CreateMcpClientForServer(); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + + var plainTool = Assert.Single(tools, t => t.Name == "plain_tool"); + + Assert.Null(plainTool.ProtocolTool.Meta?["ui"]); + } + + public sealed class AppUiTools + { + [McpServerTool(Name = "weather_tool")] + [McpAppUi(ResourceUri = "ui://weather/view.html")] + [Description("Get weather")] + public static string WeatherTool(string location) => $"Weather for {location}"; + + [McpServerTool(Name = "model_only_tool")] + [McpAppUi(ResourceUri = "ui://model-only/view.html", Visibility = [McpUiToolVisibility.Model])] + [Description("Model only")] + public static string ModelOnlyTool(string location) => $"Model only for {location}"; + + [McpServerTool(Name = "plain_tool")] + [Description("Plain tool without app UI")] + public static string PlainTool(string input) => input; + } +} From 99abe9932ec6f7aee671a68776bb8c1e30a22ea0 Mon Sep 17 00:00:00 2001 From: yayayouyou Date: Sat, 11 Jul 2026 09:38:20 +0800 Subject: [PATCH 2/2] Assert absence of _meta.ui keys instead of null value Address Copilot review on #1698: Assert.Null on a JsonObject indexer passes both when a key is absent and when it is serialized as JSON null, which doesn't prove the intended omission. Assert the "visibility" and "ui" keys are not present at all (ContainsKey) so the tests fail if the server ever emits an explicit null. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../Server/McpAppsIntegrationTests.cs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/ModelContextProtocol.Tests/Server/McpAppsIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Server/McpAppsIntegrationTests.cs index 5d95979f0..aadb6699d 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpAppsIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpAppsIntegrationTests.cs @@ -42,8 +42,9 @@ public async Task ListToolsAsync_RoundTripsMetaUi_ForToolWithAppUiAttribute() Assert.NotNull(uiNode); Assert.Equal("ui://weather/view.html", uiNode["resourceUri"]?.GetValue()); - // Visibility was not restricted, so the attribute must not add the property. - Assert.Null(uiNode["visibility"]); + // Visibility was not restricted, so the property must be absent entirely + // (not merely serialized as null). + Assert.False(uiNode.ContainsKey("visibility")); } [Fact] @@ -74,7 +75,10 @@ public async Task ListToolsAsync_HasNoUiMeta_ForToolWithoutAppUiAttribute() var plainTool = Assert.Single(tools, t => t.Name == "plain_tool"); - Assert.Null(plainTool.ProtocolTool.Meta?["ui"]); + // The tool must carry no "ui" metadata at all: either no _meta object, + // or a _meta without the "ui" key (not a "ui": null entry). + JsonObject? meta = plainTool.ProtocolTool.Meta; + Assert.False(meta is not null && meta.ContainsKey("ui")); } public sealed class AppUiTools