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);
+ }
+ }
+}