diff --git a/docs/concepts/apps/apps.md b/docs/concepts/apps/apps.md index f22479966..8fa1f181b 100644 --- a/docs/concepts/apps/apps.md +++ b/docs/concepts/apps/apps.md @@ -34,9 +34,28 @@ The key concepts are: ## Associating tools with UI resources -### Using the builder extension (recommended) +### Registering a tool and its UI resource together (recommended) -The simplest approach is to apply `[McpAppUi]` attributes to your tool methods and call `WithMcpApps()` on the server builder: +`WithAppTool` creates the tool, links it to a `ui://` resource, registers the HTML content with the MCP Apps MIME type, and enables MCP Apps support: + +```csharp +builder.Services.AddMcpServer() + .WithAppTool( + (string location) => $"Weather for {location}", + "ui://weather/view.html", + () => File.ReadAllText("weather.html")); +``` + +The `resourceUri` argument is authoritative and must be a concrete, absolute `ui://` URI; URI templates are not accepted. +If the tool options already contain `_meta.ui.resourceUri`, it must exactly match the argument, while other UI metadata is preserved. +The HTML handler may be synchronous or asynchronous and can accept a `CancellationToken` through the existing resource-handler binding. + +When multiple app tools use the same resource URI, the existing resource collection semantics apply: the first registered HTML handler serves that URI. +Use the lower-level registration APIs when the tool or resource needs additional configuration. + +### Using attributes with registered tool types + +For tools declared in a type, apply `[McpAppUi]` attributes to the tool methods and call `WithMcpApps()` on the server builder: ```csharp [McpServerToolType] diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsBuilderExtensions.cs index a68d8fe50..13638d4ad 100644 --- a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsBuilderExtensions.cs @@ -3,6 +3,7 @@ using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Nodes; namespace ModelContextProtocol.Extensions.Apps; @@ -12,6 +13,120 @@ namespace ModelContextProtocol.Extensions.Apps; [Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)] public static class McpAppsBuilderExtensions { + /// + /// Registers a tool together with the HTML resource it renders. + /// + /// The server builder. + /// The tool method to expose. + /// The absolute ui:// resource URI associated with the tool. + /// A resource handler that returns the HTML, synchronously or asynchronously. + /// Optional options used when creating the tool. + /// The builder provided in . + /// + /// , , , or + /// is . + /// + /// + /// is not an absolute, non-templated ui:// URI, or conflicts with the + /// tool's existing _meta.ui.resourceUri value. + /// + /// + /// + /// This is the compact equivalent of creating a tool with , + /// applying , and creating a resource with + /// . The resource is registered with + /// , and the returned HTML is wrapped by the existing resource result conversion. + /// + /// + /// Calling this method also enables so the server advertises MCP Apps support. + /// Existing UI metadata is preserved. If it already contains + /// ui.resourceUri, that value must exactly match . + /// + /// + /// + /// + /// builder.Services + /// .AddMcpServer() + /// .WithAppTool( + /// (string location) => $"Weather for {location}", + /// "ui://weather/view.html", + /// () => File.ReadAllText("weather.html")); + /// + /// + public static IMcpServerBuilder WithAppTool( + this IMcpServerBuilder builder, + Delegate method, + string resourceUri, + Delegate htmlFactory, + McpServerToolCreateOptions? toolOptions = null) + { +#if NET + ArgumentNullException.ThrowIfNull(builder); + ArgumentNullException.ThrowIfNull(method); + ArgumentNullException.ThrowIfNull(resourceUri); + ArgumentNullException.ThrowIfNull(htmlFactory); +#else + if (builder is null) throw new ArgumentNullException(nameof(builder)); + if (method is null) throw new ArgumentNullException(nameof(method)); + if (resourceUri is null) throw new ArgumentNullException(nameof(resourceUri)); + if (htmlFactory is null) throw new ArgumentNullException(nameof(htmlFactory)); +#endif + if (resourceUri.Contains('{') || resourceUri.Contains('}')) + { + throw new ArgumentException("The resource URI must identify a concrete UI resource and cannot be a URI template.", nameof(resourceUri)); + } + + if (string.IsNullOrWhiteSpace(resourceUri) || + !Uri.TryCreate(resourceUri, UriKind.Absolute, out Uri? parsedUri) || + !parsedUri.IsWellFormedOriginalString() || + !parsedUri.Scheme.Equals("ui", StringComparison.OrdinalIgnoreCase) || + !resourceUri.StartsWith("ui://", StringComparison.OrdinalIgnoreCase) || + (parsedUri.Host.Length == 0 && parsedUri.AbsolutePath.Length <= 1)) + { + throw new ArgumentException("The resource URI must be a valid absolute URI using the ui:// scheme.", nameof(resourceUri)); + } + + var tool = McpApps.SetAppUi( + McpServerTool.Create(method, toolOptions), + new McpUiToolMeta { ResourceUri = resourceUri }); + + if (tool.ProtocolTool.Meta?["ui"] is not JsonObject uiMetadata) + { + throw new ArgumentException("The tool's _meta.ui value must be an object.", nameof(resourceUri)); + } + + if (uiMetadata["resourceUri"] is { } resourceUriNode) + { + if (resourceUriNode is not JsonValue resourceUriValue || + !resourceUriValue.TryGetValue(out string? existingResourceUri)) + { + throw new ArgumentException("The tool's _meta.ui.resourceUri value must be a string.", nameof(resourceUri)); + } + + if (!string.Equals(existingResourceUri, resourceUri, StringComparison.Ordinal)) + { + throw new ArgumentException( + $"The tool's UI resource URI '{existingResourceUri}' does not match the registered resource URI '{resourceUri}'.", + nameof(resourceUri)); + } + } + + uiMetadata["resourceUri"] = resourceUri; + + var resource = McpServerResource.Create( + htmlFactory, + new McpServerResourceCreateOptions + { + UriTemplate = resourceUri, + MimeType = McpApps.HtmlMimeType, + }); + + return builder + .WithTools([tool]) + .WithResources([resource]) + .WithMcpApps(); + } + /// /// Enables MCP Apps support by automatically processing on registered tools. /// diff --git a/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs b/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs index 756417de0..06f37fc1d 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs @@ -470,6 +470,166 @@ public void WithMcpApps_AdvertisesServerCapability() #endregion + #region WithAppTool + + [Fact] + public async Task WithAppTool_RegistersLinkedToolAndHtmlResource() + { + var services = new ServiceCollection(); + services.AddMcpServer() + .WithAppTool( + (string location) => $"Weather for {location}", + "ui://weather/view.html", + () => "weather", + new McpServerToolCreateOptions + { + Name = "weather", + Description = "Gets weather", + Meta = new JsonObject { ["custom"] = "value" }, + }); + + await using var serviceProvider = services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + var tool = Assert.Single(options.ToolCollection!); + var resource = Assert.Single(options.ResourceCollection!); + + Assert.Equal("weather", tool.ProtocolTool.Name); + Assert.Equal("Gets weather", tool.ProtocolTool.Description); + Assert.Equal("value", tool.ProtocolTool.Meta?["custom"]?.GetValue()); + Assert.Equal("ui://weather/view.html", tool.ProtocolTool.Meta?["ui"]?["resourceUri"]?.GetValue()); + Assert.Equal("ui://weather/view.html", resource.ProtocolResourceTemplate.UriTemplate); + Assert.Equal(McpApps.HtmlMimeType, resource.ProtocolResourceTemplate.MimeType); + Assert.Contains(McpApps.ExtensionId, options.Capabilities!.Extensions!.Keys); + } + + [Fact] + public async Task WithAppTool_PreservesMatchingToolUiMetadata() + { + var services = new ServiceCollection(); + services.AddMcpServer() + .WithAppTool( + () => "result", + "ui://explicit/view.html", + () => "", + new McpServerToolCreateOptions + { + Name = "app_tool", + Meta = new JsonObject + { + ["ui"] = new JsonObject + { + ["resourceUri"] = "ui://explicit/view.html", + ["visibility"] = new JsonArray(McpUiToolVisibility.App), + }, + }, + }); + + await using var serviceProvider = services.BuildServiceProvider(); + var tool = Assert.Single(serviceProvider.GetRequiredService>().Value.ToolCollection!); + + Assert.Equal("ui://explicit/view.html", tool.ProtocolTool.Meta?["ui"]?["resourceUri"]?.GetValue()); + Assert.Equal(McpUiToolVisibility.App, tool.ProtocolTool.Meta?["ui"]?["visibility"]?[0]?.GetValue()); + } + + [Fact] + public async Task WithAppTool_AddsResourceUriToExistingToolUiMetadata() + { + var services = new ServiceCollection(); + services.AddMcpServer() + .WithAppTool( + () => "result", + "ui://weather/view.html", + () => "", + new McpServerToolCreateOptions + { + Name = "app_tool", + Meta = new JsonObject + { + ["ui"] = new JsonObject + { + ["visibility"] = new JsonArray(McpUiToolVisibility.Model), + }, + }, + }); + + await using var serviceProvider = services.BuildServiceProvider(); + var tool = Assert.Single(serviceProvider.GetRequiredService>().Value.ToolCollection!); + + Assert.Equal("ui://weather/view.html", tool.ProtocolTool.Meta?["ui"]?["resourceUri"]?.GetValue()); + Assert.Equal(McpUiToolVisibility.Model, tool.ProtocolTool.Meta?["ui"]?["visibility"]?[0]?.GetValue()); + } + + [Fact] + public void WithAppTool_RejectsConflictingToolUiMetadata() + { + var builder = new ServiceCollection().AddMcpServer(); + + var exception = Assert.Throws(() => builder.WithAppTool( + () => "result", + "ui://weather/view.html", + () => "", + new McpServerToolCreateOptions + { + Name = "app_tool", + Meta = new JsonObject + { + ["ui"] = new JsonObject { ["resourceUri"] = "ui://other/view.html" }, + }, + })); + + Assert.Equal("resourceUri", exception.ParamName); + Assert.Contains("ui://other/view.html", exception.Message); + Assert.Contains("ui://weather/view.html", exception.Message); + } + + [Fact] + public async Task WithAppTool_DuplicateResourceUriKeepsSingleResource() + { + var services = new ServiceCollection(); + services.AddMcpServer() + .WithAppTool(() => "first", "ui://shared/view.html", () => "first", new() { Name = "first" }) + .WithAppTool(() => "second", "ui://shared/view.html", () => "second", new() { Name = "second" }); + + await using var serviceProvider = services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + Assert.Equal(2, options.ToolCollection!.Count); + Assert.Single(options.ResourceCollection!); + } + + [Fact] + public void WithAppTool_RejectsMissingConfiguration() + { + var builder = new ServiceCollection().AddMcpServer(); + Func htmlFactory = () => "html"; + Delegate method = () => "result"; + + Assert.Throws(() => builder.WithAppTool(null!, "ui://test", htmlFactory)); + Assert.Throws(() => builder.WithAppTool(method, null!, htmlFactory)); + Assert.Throws(() => builder.WithAppTool(method, "ui://test", null!)); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData("weather/view.html")] + [InlineData("https://weather.example/view.html")] + [InlineData("ui:/weather/view.html")] + [InlineData("ui://")] + [InlineData("ui://weather/{view}.html")] + public void WithAppTool_RejectsInvalidResourceUri(string resourceUri) + { + var builder = new ServiceCollection().AddMcpServer(); + Delegate method = () => "result"; + Func htmlFactory = () => "html"; + + var exception = Assert.Throws(() => builder.WithAppTool(method, resourceUri, htmlFactory)); + + Assert.Equal("resourceUri", exception.ParamName); + } + + #endregion + #region Test helper types [McpServerToolType] diff --git a/tests/ModelContextProtocol.Tests/Server/McpAppsWithAppToolIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Server/McpAppsWithAppToolIntegrationTests.cs new file mode 100644 index 000000000..8e685d865 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpAppsWithAppToolIntegrationTests.cs @@ -0,0 +1,96 @@ +#pragma warning disable MCPEXP003 + +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Apps; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Verifies that preserves the +/// tool and resource behavior across a client/server round trip. +/// +public sealed class McpAppsWithAppToolIntegrationTests : ClientServerTestBase +{ + private const string AppResourceUri = "ui://weather/view.html"; + private const string AppHtml = "weather"; + + public McpAppsWithAppToolIntegrationTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder + .WithAppTool(AppTools.GetWeather, AppResourceUri, AppTools.GetHtmlAsync) + .WithAppTool(AppTools.GetWeatherSummary, AppResourceUri, static () => "ignored"); + } + + [Fact] + public async Task WithAppTool_RoundTripsToolMetadataAndParameters() + { + await using McpClient client = await CreateMcpClientForServer(); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(2, tools.Count); + var tool = Assert.Single(tools, t => t.Name == "weather"); + + Assert.Equal("weather", tool.Name); + Assert.Equal("Gets weather for a location", tool.Description); + Assert.Equal("ui://weather/view.html", tool.ProtocolTool.Meta?["ui"]?["resourceUri"]?.GetValue()); + Assert.Contains("location", tool.ProtocolTool.InputSchema.GetProperty("properties").EnumerateObject().Select(p => p.Name)); + + var result = await client.CallToolAsync( + "weather", + new Dictionary { ["location"] = "Paris" }, + cancellationToken: TestContext.Current.CancellationToken); + + var text = Assert.IsType(Assert.Single(result.Content)); + Assert.Equal("Weather for Paris", text.Text); + } + + [Fact] + public async Task WithAppTool_DuplicateResourceUriUsesFirstHtmlHandler() + { + await using McpClient client = await CreateMcpClientForServer(); + + var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken); + var resource = Assert.Single(resources); + Assert.Equal(AppResourceUri, resource.Uri); + Assert.Equal(McpApps.HtmlMimeType, resource.MimeType); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.All(tools, tool => + Assert.Equal(resource.Uri, tool.ProtocolTool.Meta?["ui"]?["resourceUri"]?.GetValue())); + + var result = await client.ReadResourceAsync( + resource.Uri, + cancellationToken: TestContext.Current.CancellationToken); + + var content = Assert.IsType(Assert.Single(result.Contents)); + Assert.Equal(resource.Uri, content.Uri); + Assert.Equal(McpApps.HtmlMimeType, content.MimeType); + Assert.Equal(AppHtml, content.Text); + } + + private static class AppTools + { + [McpServerTool(Name = "weather")] + [Description("Gets weather for a location")] + public static string GetWeather(string location) => $"Weather for {location}"; + + [McpServerTool(Name = "weather_summary")] + [Description("Gets a weather summary")] + public static string GetWeatherSummary() => "Weather summary"; + + public static Task GetHtmlAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(AppHtml); + } + } +}