#pragma warning disable MCPEXP003 using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using ModelContextProtocol.Extensions.Apps; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.ComponentModel; using System.Text.Json; using System.Text.Json.Nodes; namespace ModelContextProtocol.Tests.Server; /// /// Tests for MCP Apps extension support: McpApps constants, typed metadata models, /// McpAppUiAttribute, SetAppUi, and ApplyAppUiAttributes. /// public class McpAppsTests { #region F1: Constants [Fact] public void McpApps_Constants_HaveExpectedValues() { Assert.Equal("text/html;profile=mcp-app", McpApps.HtmlMimeType); Assert.Equal("io.modelcontextprotocol/ui", McpApps.ExtensionId); } [Fact] public void McpUiToolVisibility_Constants_HaveExpectedValues() { Assert.Equal("model", McpUiToolVisibility.Model); Assert.Equal("app", McpUiToolVisibility.App); } #endregion #region F2: Typed Metadata Models [Fact] public void McpUiToolMeta_DefaultsToNull() { var meta = new McpUiToolMeta(); Assert.Null(meta.ResourceUri); Assert.Null(meta.Visibility); } [Fact] public void McpUiToolMeta_CanBeRoundtrippedAsJson() { var meta = new McpUiToolMeta { ResourceUri = "ui://weather/view.html", Visibility = [McpUiToolVisibility.Model, McpUiToolVisibility.App], }; var json = JsonSerializer.Serialize(meta, McpApps.SerializerOptions); var deserialized = JsonSerializer.Deserialize(json, McpApps.SerializerOptions); Assert.NotNull(deserialized); Assert.Equal("ui://weather/view.html", deserialized.ResourceUri); Assert.Equal(["model", "app"], deserialized.Visibility); } [Fact] public void McpUiToolMeta_OmitsNullProperties() { var meta = new McpUiToolMeta { ResourceUri = "ui://app" }; var json = JsonSerializer.Serialize(meta, McpApps.SerializerOptions); var doc = JsonDocument.Parse(json); Assert.True(doc.RootElement.TryGetProperty("resourceUri", out _)); Assert.False(doc.RootElement.TryGetProperty("visibility", out _)); } [Fact] public void McpUiResourceMeta_CanBeRoundtrippedAsJson() { var meta = new McpUiResourceMeta { Domain = "https://app.example.com", PrefersBorder = true, Csp = new McpUiResourceCsp { ConnectDomains = ["https://api.example.com"], ResourceDomains = ["https://cdn.example.com"], FrameDomains = ["https://embed.example.com"], BaseUris = ["https://app.example.com"], }, Permissions = new McpUiResourcePermissions { Allow = ["camera", "microphone"], }, }; var json = JsonSerializer.Serialize(meta, McpApps.SerializerOptions); var deserialized = JsonSerializer.Deserialize(json, McpApps.SerializerOptions); Assert.NotNull(deserialized); Assert.Equal("https://app.example.com", deserialized.Domain); Assert.True(deserialized.PrefersBorder); Assert.NotNull(deserialized.Csp); Assert.Equal(["https://api.example.com"], deserialized.Csp.ConnectDomains); Assert.Equal(["https://cdn.example.com"], deserialized.Csp.ResourceDomains); Assert.Equal(["https://embed.example.com"], deserialized.Csp.FrameDomains); Assert.Equal(["https://app.example.com"], deserialized.Csp.BaseUris); Assert.NotNull(deserialized.Permissions); Assert.Equal(["camera", "microphone"], deserialized.Permissions.Allow); } [Fact] public void McpUiClientCapabilities_CanBeRoundtrippedAsJson() { var caps = new McpUiClientCapabilities { MimeTypes = [McpApps.HtmlMimeType], }; var json = JsonSerializer.Serialize(caps, McpApps.SerializerOptions); var deserialized = JsonSerializer.Deserialize(json, McpApps.SerializerOptions); Assert.NotNull(deserialized); Assert.Equal([McpApps.HtmlMimeType], deserialized.MimeTypes); } #endregion #region F3: GetUiCapability [Fact] public void GetUiCapability_ReturnsNull_WhenCapabilitiesIsNull() { Assert.Null(McpApps.GetUiCapability(null)); } [Fact] public void GetUiCapability_ReturnsNull_WhenExtensionsIsNull() { var caps = new ClientCapabilities(); Assert.Null(McpApps.GetUiCapability(caps)); } [Fact] public void GetUiCapability_ReturnsNull_WhenExtensionKeyIsMissing() { #pragma warning disable MCPEXP001 var caps = new ClientCapabilities { Extensions = new Dictionary { ["other.extension"] = new { }, } }; #pragma warning restore MCPEXP001 Assert.Null(McpApps.GetUiCapability(caps)); } [Fact] public void GetUiCapability_ReturnsCapabilities_WhenExtensionIsPresent() { // Simulate what the SDK does when deserializing ClientCapabilities from JSON: // extensions values come in as JsonElement. var json = $$""" { "extensions": { "{{McpApps.ExtensionId}}": { "mimeTypes": ["{{McpApps.HtmlMimeType}}"] } } } """; var caps = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); Assert.NotNull(caps); var uiCaps = McpApps.GetUiCapability(caps); Assert.NotNull(uiCaps); Assert.Equal([McpApps.HtmlMimeType], uiCaps.MimeTypes); } [Fact] public void GetUiCapability_ReturnsNull_WhenExtensionValueIsNull() { var json = $$""" { "extensions": { "{{McpApps.ExtensionId}}": null } } """; var caps = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); Assert.NotNull(caps); Assert.Null(McpApps.GetUiCapability(caps)); } [Theory] [InlineData("\"a string value\"")] [InlineData("42")] [InlineData("true")] [InlineData("[1, 2, 3]")] public void GetUiCapability_ReturnsNull_WhenExtensionValueIsMalformed(string malformedValue) { var json = $$""" { "extensions": { "{{McpApps.ExtensionId}}": {{malformedValue}} } } """; var caps = JsonSerializer.Deserialize(json, McpJsonUtilities.DefaultOptions); Assert.NotNull(caps); // Should return null gracefully, not throw Assert.Null(McpApps.GetUiCapability(caps)); } [Fact] public void GetUiCapability_ReturnsCapabilities_WhenValueIsStronglyTyped() { #pragma warning disable MCPEXP001 var caps = new ClientCapabilities { Extensions = new Dictionary { [McpApps.ExtensionId] = new McpUiClientCapabilities { MimeTypes = [McpApps.HtmlMimeType], }, } }; #pragma warning restore MCPEXP001 var uiCaps = McpApps.GetUiCapability(caps); Assert.NotNull(uiCaps); Assert.Equal([McpApps.HtmlMimeType], uiCaps.MimeTypes); } #endregion #region F6: McpAppUiAttribute via ApplyAppUiAttributes [Fact] public void ApplyAppUiAttributes_PopulatesUiObject() { var method = typeof(TestToolsWithAppUi).GetMethod(nameof(TestToolsWithAppUi.WeatherTool))!; var tool = McpServerTool.Create(method, target: null); McpApps.ApplyAppUiAttributes(tool); var meta = tool.ProtocolTool.Meta; Assert.NotNull(meta); // Structured "ui" object var uiNode = meta["ui"]?.AsObject(); Assert.NotNull(uiNode); Assert.Equal("ui://weather/view.html", uiNode["resourceUri"]?.GetValue()); } [Fact] public void ApplyAppUiAttributes_WithVisibility_IncludesVisibilityInUiObject() { var method = typeof(TestToolsWithAppUi).GetMethod(nameof(TestToolsWithAppUi.ModelOnlyTool))!; var tool = McpServerTool.Create(method, target: null); McpApps.ApplyAppUiAttributes(tool); var uiNode = tool.ProtocolTool.Meta?["ui"]?.AsObject(); Assert.NotNull(uiNode); Assert.Equal("ui://model-only/view.html", uiNode["resourceUri"]?.GetValue()); var visibility = uiNode["visibility"]?.AsArray(); Assert.NotNull(visibility); Assert.Single(visibility); Assert.Equal(McpUiToolVisibility.Model, visibility[0]?.GetValue()); } [Fact] public void ApplyAppUiAttributes_ExplicitMeta_TakesPrecedence() { // Explicit Meta["ui"] in options should override the attribute var method = typeof(TestToolsWithAppUi).GetMethod(nameof(TestToolsWithAppUi.WeatherTool))!; var explicitMeta = new JsonObject { ["ui"] = new JsonObject { ["resourceUri"] = "ui://explicit/override.html" }, }; var tool = McpServerTool.Create(method, target: null, new McpServerToolCreateOptions { Meta = explicitMeta }); McpApps.ApplyAppUiAttributes(tool); var uiNode = tool.ProtocolTool.Meta?["ui"]?.AsObject(); // Explicit Meta["ui"] wins — ApplyAppUiAttributes does not overwrite Assert.Equal("ui://explicit/override.html", uiNode?["resourceUri"]?.GetValue()); } [Fact] public void ApplyAppUiAttributes_Collection_ProcessesAllTools() { var tools = new[] { McpServerTool.Create(typeof(TestToolsWithAppUi).GetMethod(nameof(TestToolsWithAppUi.WeatherTool))!, target: null), McpServerTool.Create(typeof(TestToolsWithAppUi).GetMethod(nameof(TestToolsWithAppUi.ModelOnlyTool))!, target: null), }; McpApps.ApplyAppUiAttributes(tools); Assert.NotNull(tools[0].ProtocolTool.Meta?["ui"]); Assert.NotNull(tools[1].ProtocolTool.Meta?["ui"]); } [Fact] public void ApplyAppUiAttributes_NoAttribute_DoesNothing() { var tool = McpServerTool.Create( (string input) => input, new McpServerToolCreateOptions { Name = "plain_tool" }); McpApps.ApplyAppUiAttributes(tool); Assert.Null(tool.ProtocolTool.Meta); } #endregion #region F7: SetAppUi [Fact] public void SetAppUi_PopulatesUiObject() { var tool = McpServerTool.Create( (string location) => $"Weather for {location}", new McpServerToolCreateOptions { Name = "get_weather" }); McpApps.SetAppUi(tool, new McpUiToolMeta { ResourceUri = "ui://weather/view.html" }); var meta = tool.ProtocolTool.Meta; Assert.NotNull(meta); var uiNode = meta["ui"]?.AsObject(); Assert.NotNull(uiNode); Assert.Equal("ui://weather/view.html", uiNode["resourceUri"]?.GetValue()); } [Fact] public void SetAppUi_WithVisibility_IncludesVisibilityInUiObject() { var tool = McpServerTool.Create( (string location) => $"Weather for {location}", new McpServerToolCreateOptions { Name = "get_weather" }); McpApps.SetAppUi(tool, new McpUiToolMeta { ResourceUri = "ui://weather/view.html", Visibility = [McpUiToolVisibility.Model], }); var uiNode = tool.ProtocolTool.Meta?["ui"]?.AsObject(); Assert.NotNull(uiNode); var visibility = uiNode["visibility"]?.AsArray(); Assert.NotNull(visibility); Assert.Single(visibility); Assert.Equal(McpUiToolVisibility.Model, visibility[0]?.GetValue()); } [Fact] public void SetAppUi_DoesNotOverwrite_ExistingUiKey() { var tool = McpServerTool.Create( (string location) => $"Weather for {location}", new McpServerToolCreateOptions { Name = "get_weather", Meta = new JsonObject { ["ui"] = new JsonObject { ["resourceUri"] = "ui://explicit/view.html" }, }, }); McpApps.SetAppUi(tool, new McpUiToolMeta { ResourceUri = "ui://new/view.html" }); // Existing Meta["ui"] is preserved var uiNode = tool.ProtocolTool.Meta?["ui"]?.AsObject(); Assert.Equal("ui://explicit/view.html", uiNode?["resourceUri"]?.GetValue()); } [Fact] public void SetAppUi_NullResourceUri_ProducesUiObjectWithoutResourceUri() { var tool = McpServerTool.Create( (string location) => $"Weather for {location}", new McpServerToolCreateOptions { Name = "get_weather" }); McpApps.SetAppUi(tool, new McpUiToolMeta { Visibility = [McpUiToolVisibility.App] }); var uiNode = tool.ProtocolTool.Meta?["ui"]?.AsObject(); Assert.NotNull(uiNode); Assert.Null(uiNode["resourceUri"]); } [Fact] public void SetAppUi_ReturnsSameTool() { var tool = McpServerTool.Create( (string location) => $"Weather for {location}", new McpServerToolCreateOptions { Name = "get_weather" }); var result = McpApps.SetAppUi(tool, new McpUiToolMeta { ResourceUri = "ui://weather/view.html" }); Assert.Same(tool, result); } #endregion #region Builder Extension: WithMcpApps [Fact] public void WithMcpApps_AppliesAppUiAttributes_ViaOptions() { var sc = new ServiceCollection(); sc.AddMcpServer() .WithTools([typeof(TestToolsWithAppUi)]) .WithMcpApps(); using var sp = sc.BuildServiceProvider(); var options = sp.GetRequiredService>().Value; Assert.NotNull(options.ToolCollection); Assert.NotEmpty(options.ToolCollection); // Both tools should have their [McpAppUi] attributes applied var toolsWithUi = options.ToolCollection.Where(t => t.ProtocolTool.Meta?["ui"] is not null).ToList(); Assert.Equal(2, toolsWithUi.Count); var weatherTool = toolsWithUi.First(t => t.ProtocolTool.Meta!["ui"]!["resourceUri"]?.GetValue() == "ui://weather/view.html"); Assert.NotNull(weatherTool); } [Fact] public void WithMcpApps_EmptyToolCollection_DoesNotThrow() { var sc = new ServiceCollection(); sc.AddMcpServer() .WithMcpApps(); using var sp = sc.BuildServiceProvider(); var options = sp.GetRequiredService>().Value; // Should be a no-op when no tools are registered Assert.True(options.ToolCollection is null || options.ToolCollection.IsEmpty); } [Fact] public void WithMcpApps_AdvertisesServerCapability() { var sc = new ServiceCollection(); sc.AddMcpServer() .WithMcpApps(); using var sp = sc.BuildServiceProvider(); var options = sp.GetRequiredService>().Value; Assert.NotNull(options.Capabilities); Assert.NotNull(options.Capabilities.Extensions); Assert.True(options.Capabilities.Extensions.ContainsKey(McpApps.ExtensionId)); } #endregion #region Test helper types [McpServerToolType] private static class TestToolsWithAppUi { [McpServerTool] [McpAppUi(ResourceUri = "ui://weather/view.html")] [Description("Get weather")] public static string WeatherTool(string location) => $"Weather for {location}"; [McpServerTool] [McpAppUi(ResourceUri = "ui://model-only/view.html", Visibility = [McpUiToolVisibility.Model])] public static string ModelOnlyTool(string location) => $"Model only for {location}"; } #endregion }