489 lines
16 KiB
C#
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
|
|
}
|