diff --git a/tests/ModelContextProtocol.Tests/Server/McpAppsIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Server/McpAppsIntegrationTests.cs new file mode 100644 index 000000000..aadb6699d --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpAppsIntegrationTests.cs @@ -0,0 +1,100 @@ +#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 property must be absent entirely + // (not merely serialized as null). + Assert.False(uiNode.ContainsKey("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"); + + // 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 + { + [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; + } +}