From dc1de3a521b750ddb85cedee1f0ca7a1154ed408 Mon Sep 17 00:00:00 2001
From: KirschQAQ <114209152+KirschBluteX@users.noreply.github.com>
Date: Thu, 13 Aug 2026 05:25:47 -0700
Subject: [PATCH 1/2] Add WithAppTool helper for MCP Apps
---
docs/concepts/apps/apps.md | 18 +++-
.../Server/McpAppsBuilderExtensions.cs | 77 +++++++++++++++++
.../Server/McpAppsTests.cs | 86 +++++++++++++++++++
.../McpAppsWithAppToolIntegrationTests.cs | 79 +++++++++++++++++
4 files changed, 258 insertions(+), 2 deletions(-)
create mode 100644 tests/ModelContextProtocol.Tests/Server/McpAppsWithAppToolIntegrationTests.cs
diff --git a/docs/concepts/apps/apps.md b/docs/concepts/apps/apps.md
index f22479966..dd9f33230 100644
--- a/docs/concepts/apps/apps.md
+++ b/docs/concepts/apps/apps.md
@@ -34,9 +34,23 @@ 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"));
+```
+
+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..b7bc21d8b 100644
--- a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsBuilderExtensions.cs
+++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsBuilderExtensions.cs
@@ -12,6 +12,83 @@ 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 ui:// resource URI associated with the tool.
+ /// A callback that returns the HTML for the UI resource.
+ /// Optional options used when creating the tool.
+ /// The builder provided in .
+ ///
+ /// , , , or
+ /// is .
+ ///
+ /// is empty or consists only of whitespace.
+ ///
+ ///
+ /// 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, as it is with
+ /// .
+ ///
+ ///
+ ///
+ ///
+ /// 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,
+ Func 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 (string.IsNullOrWhiteSpace(resourceUri))
+ {
+ throw new ArgumentException("Value cannot be empty or composed entirely of whitespace.", nameof(resourceUri));
+ }
+
+ var tool = McpApps.SetAppUi(
+ McpServerTool.Create(method, toolOptions),
+ new McpUiToolMeta { 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..8fb1d28c4 100644
--- a/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs
@@ -470,6 +470,92 @@ 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_PreservesExplicitToolUiMetadata()
+ {
+ var services = new ServiceCollection();
+ services.AddMcpServer()
+ .WithAppTool(
+ () => "result",
+ "ui://default/view.html",
+ () => "",
+ new McpServerToolCreateOptions
+ {
+ Name = "app_tool",
+ Meta = new JsonObject
+ {
+ ["ui"] = new JsonObject { ["resourceUri"] = "ui://explicit/view.html" },
+ },
+ });
+
+ 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());
+ }
+
+ [Fact]
+ public async Task WithAppTool_DuplicateResourceUriUsesExistingCollectionSemantics()
+ {
+ 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!));
+ Assert.Throws(() => builder.WithAppTool(method, " ", htmlFactory));
+ }
+
+ #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..2fdcbd7d0
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Server/McpAppsWithAppToolIntegrationTests.cs
@@ -0,0 +1,79 @@
+#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
+{
+ public McpAppsWithAppToolIntegrationTests(ITestOutputHelper testOutputHelper)
+ : base(testOutputHelper)
+ {
+ }
+
+ protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
+ {
+ mcpServerBuilder.WithAppTool(
+ AppTools.GetWeather,
+ "ui://weather/view.html",
+ static () => "weather");
+ }
+
+ [Fact]
+ public async Task WithAppTool_RoundTripsToolMetadataAndParameters()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
+ var tool = Assert.Single(tools);
+
+ 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_UsesAppMimeTypeAndFactoryContent()
+ {
+ await using McpClient client = await CreateMcpClientForServer();
+
+ var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
+ var resource = Assert.Single(resources);
+ Assert.Equal("ui://weather/view.html", resource.Uri);
+ Assert.Equal(McpApps.HtmlMimeType, resource.MimeType);
+
+ 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("weather", content.Text);
+ }
+
+ private static class AppTools
+ {
+ [McpServerTool(Name = "weather")]
+ [Description("Gets weather for a location")]
+ public static string GetWeather(string location) => $"Weather for {location}";
+ }
+}
From f19fc75da751e25e6efbeb132e7fb5e3c4f668a8 Mon Sep 17 00:00:00 2001
From: KirschQAQ <114209152+KirschBluteX@users.noreply.github.com>
Date: Fri, 14 Aug 2026 11:29:17 -0700
Subject: [PATCH 2/2] Validate WithAppTool resource links
---
docs/concepts/apps/apps.md | 5 ++
.../Server/McpAppsBuilderExtensions.cs | 54 ++++++++++--
.../Server/McpAppsTests.cs | 84 +++++++++++++++++--
.../McpAppsWithAppToolIntegrationTests.cs | 33 ++++++--
4 files changed, 155 insertions(+), 21 deletions(-)
diff --git a/docs/concepts/apps/apps.md b/docs/concepts/apps/apps.md
index dd9f33230..8fa1f181b 100644
--- a/docs/concepts/apps/apps.md
+++ b/docs/concepts/apps/apps.md
@@ -46,6 +46,11 @@ builder.Services.AddMcpServer()
() => 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
diff --git a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsBuilderExtensions.cs b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsBuilderExtensions.cs
index b7bc21d8b..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;
@@ -17,15 +18,18 @@ public static class McpAppsBuilderExtensions
///
/// The server builder.
/// The tool method to expose.
- /// The ui:// resource URI associated with the tool.
- /// A callback that returns the HTML for the UI resource.
+ /// 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 empty or consists only of whitespace.
+ ///
+ /// 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 ,
@@ -35,8 +39,8 @@ public static class McpAppsBuilderExtensions
///
///
/// Calling this method also enables so the server advertises MCP Apps support.
- /// Existing UI metadata is preserved, as it is with
- /// .
+ /// Existing UI metadata is preserved. If it already contains
+ /// ui.resourceUri, that value must exactly match .
///
///
///
@@ -53,7 +57,7 @@ public static IMcpServerBuilder WithAppTool(
this IMcpServerBuilder builder,
Delegate method,
string resourceUri,
- Func htmlFactory,
+ Delegate htmlFactory,
McpServerToolCreateOptions? toolOptions = null)
{
#if NET
@@ -67,14 +71,48 @@ public static IMcpServerBuilder WithAppTool(
if (resourceUri is null) throw new ArgumentNullException(nameof(resourceUri));
if (htmlFactory is null) throw new ArgumentNullException(nameof(htmlFactory));
#endif
- if (string.IsNullOrWhiteSpace(resourceUri))
+ if (resourceUri.Contains('{') || resourceUri.Contains('}'))
{
- throw new ArgumentException("Value cannot be empty or composed entirely of whitespace.", nameof(resourceUri));
+ 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
diff --git a/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs b/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs
index 8fb1d28c4..06f37fc1d 100644
--- a/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs
@@ -503,20 +503,24 @@ public async Task WithAppTool_RegistersLinkedToolAndHtmlResource()
}
[Fact]
- public async Task WithAppTool_PreservesExplicitToolUiMetadata()
+ public async Task WithAppTool_PreservesMatchingToolUiMetadata()
{
var services = new ServiceCollection();
services.AddMcpServer()
.WithAppTool(
() => "result",
- "ui://default/view.html",
+ "ui://explicit/view.html",
() => "",
new McpServerToolCreateOptions
{
Name = "app_tool",
Meta = new JsonObject
{
- ["ui"] = new JsonObject { ["resourceUri"] = "ui://explicit/view.html" },
+ ["ui"] = new JsonObject
+ {
+ ["resourceUri"] = "ui://explicit/view.html",
+ ["visibility"] = new JsonArray(McpUiToolVisibility.App),
+ },
},
});
@@ -524,10 +528,62 @@ public async Task WithAppTool_PreservesExplicitToolUiMetadata()
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_DuplicateResourceUriUsesExistingCollectionSemantics()
+ 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()
@@ -551,7 +607,25 @@ public void WithAppTool_RejectsMissingConfiguration()
Assert.Throws(() => builder.WithAppTool(null!, "ui://test", htmlFactory));
Assert.Throws(() => builder.WithAppTool(method, null!, htmlFactory));
Assert.Throws(() => builder.WithAppTool(method, "ui://test", null!));
- Assert.Throws(() => builder.WithAppTool(method, " ", htmlFactory));
+ }
+
+ [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
diff --git a/tests/ModelContextProtocol.Tests/Server/McpAppsWithAppToolIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Server/McpAppsWithAppToolIntegrationTests.cs
index 2fdcbd7d0..8e685d865 100644
--- a/tests/ModelContextProtocol.Tests/Server/McpAppsWithAppToolIntegrationTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/McpAppsWithAppToolIntegrationTests.cs
@@ -15,6 +15,9 @@ namespace ModelContextProtocol.Tests.Server;
///
public sealed class McpAppsWithAppToolIntegrationTests : ClientServerTestBase
{
+ private const string AppResourceUri = "ui://weather/view.html";
+ private const string AppHtml = "weather";
+
public McpAppsWithAppToolIntegrationTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper)
{
@@ -22,10 +25,9 @@ public McpAppsWithAppToolIntegrationTests(ITestOutputHelper testOutputHelper)
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
- mcpServerBuilder.WithAppTool(
- AppTools.GetWeather,
- "ui://weather/view.html",
- static () => "weather");
+ mcpServerBuilder
+ .WithAppTool(AppTools.GetWeather, AppResourceUri, AppTools.GetHtmlAsync)
+ .WithAppTool(AppTools.GetWeatherSummary, AppResourceUri, static () => "ignored");
}
[Fact]
@@ -34,7 +36,8 @@ public async Task WithAppTool_RoundTripsToolMetadataAndParameters()
await using McpClient client = await CreateMcpClientForServer();
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
- var tool = Assert.Single(tools);
+ 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);
@@ -51,15 +54,19 @@ public async Task WithAppTool_RoundTripsToolMetadataAndParameters()
}
[Fact]
- public async Task WithAppTool_UsesAppMimeTypeAndFactoryContent()
+ 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("ui://weather/view.html", resource.Uri);
+ 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);
@@ -67,7 +74,7 @@ public async Task WithAppTool_UsesAppMimeTypeAndFactoryContent()
var content = Assert.IsType(Assert.Single(result.Contents));
Assert.Equal(resource.Uri, content.Uri);
Assert.Equal(McpApps.HtmlMimeType, content.MimeType);
- Assert.Equal("weather", content.Text);
+ Assert.Equal(AppHtml, content.Text);
}
private static class AppTools
@@ -75,5 +82,15 @@ 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);
+ }
}
}