csharp-sdk/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs

489 lines
16 KiB
C#

#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;
/// <summary>
/// Tests for MCP Apps extension support: McpApps constants, typed metadata models,
/// McpAppUiAttribute, SetAppUi, and ApplyAppUiAttributes.
/// </summary>
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<McpUiToolMeta>(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<McpUiResourceMeta>(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<McpUiClientCapabilities>(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<string, object>
{
["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<ClientCapabilities>(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<ClientCapabilities>(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<ClientCapabilities>(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<string, object>
{
[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<string>());
}
[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<string>());
var visibility = uiNode["visibility"]?.AsArray();
Assert.NotNull(visibility);
Assert.Single(visibility);
Assert.Equal(McpUiToolVisibility.Model, visibility[0]?.GetValue<string>());
}
[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<string>());
}
[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<string>());
}
[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<string>());
}
[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<string>());
}
[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<IOptions<McpServerOptions>>().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<string>() == "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<IOptions<McpServerOptions>>().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<IOptions<McpServerOptions>>().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
}