diff --git a/docs/concepts/apps/apps.md b/docs/concepts/apps/apps.md index f22479966..d0ea33d88 100644 --- a/docs/concepts/apps/apps.md +++ b/docs/concepts/apps/apps.md @@ -34,9 +34,29 @@ 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 be a string that exactly matches the argument, while other UI metadata is preserved. +The HTML handler must return `string`, `Task`, or `ValueTask`. It can have no parameters or accept a single `CancellationToken`. + +When multiple app tools use the exact same resource URI, the first registered HTML handler serves that URI. +Equivalent URI spellings and collisions with resources registered through the lower-level APIs are rejected when server options are resolved, so every tool link identifies the HTML resource created by `WithAppTool`. +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..518ac8862 100644 --- a/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsBuilderExtensions.cs +++ b/src/ModelContextProtocol.Extensions.Apps/Server/McpAppsBuilderExtensions.cs @@ -1,8 +1,10 @@ using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Nodes; namespace ModelContextProtocol.Extensions.Apps; @@ -12,6 +14,279 @@ 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 as a , of + /// , or of . It may have no parameters + /// or a single parameter. + /// + /// Optional options used when creating the tool. + /// The builder provided in . + /// + /// , , , or + /// is . + /// + /// + /// is not an absolute, non-templated ui:// URI, + /// has an unsupported signature, or the tool's existing + /// _meta.ui.resourceUri value is not a string that exactly matches it. + /// + /// + /// The app tool's name or resource URI conflicts with another registration when server options are resolved. + /// + /// + /// + /// 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 be a string that exactly matches . + /// + /// + /// + /// + /// 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)); + } + + ValidateHtmlFactory(htmlFactory); + + 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(toolOptions)); + } + + if (uiMetadata.ContainsKey("resourceUri")) + { + JsonNode? resourceUriNode = uiMetadata["resourceUri"]; + 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(toolOptions)); + } + + 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(toolOptions)); + } + } + + uiMetadata["resourceUri"] = resourceUri; + + var resource = McpServerResource.Create( + htmlFactory, + new McpServerResourceCreateOptions + { + UriTemplate = resourceUri, + MimeType = McpApps.HtmlMimeType, + }); + + builder.Services.AddSingleton(new AppToolRegistration(tool, resource, resourceUri)); + builder.Services.TryAddEnumerable( + ServiceDescriptor.Singleton, AppToolOptionsValidator>()); + + return builder + .WithTools([tool]) + .WithResources([resource]) + .WithMcpApps(); + } + + private static void ValidateHtmlFactory(Delegate htmlFactory) + { + Type returnType = htmlFactory.Method.ReturnType; + if (returnType != typeof(string) && + returnType != typeof(Task) && + returnType != typeof(ValueTask)) + { + throw new ArgumentException( + "The HTML factory must return string, Task, or ValueTask.", + nameof(htmlFactory)); + } + + var parameters = htmlFactory.Method.GetParameters(); + if (parameters.Length > 1 || + (parameters.Length == 1 && parameters[0].ParameterType != typeof(CancellationToken))) + { + throw new ArgumentException( + "The HTML factory must have no parameters or a single CancellationToken parameter.", + nameof(htmlFactory)); + } + } + + private sealed class AppToolRegistration( + McpServerTool tool, + McpServerResource resource, + string resourceUri) + { + public McpServerTool Tool { get; } = tool; + + public McpServerResource Resource { get; } = resource; + + public string ResourceUri { get; } = resourceUri; + } + + private sealed class AppToolOptionsValidator( + IEnumerable registrations, + IEnumerable registeredTools, + IEnumerable registeredResources) : IValidateOptions + { + private readonly AppToolRegistration[] _registrations = registrations.ToArray(); + private readonly McpServerTool[] _registeredTools = registeredTools.ToArray(); + private readonly McpServerResource[] _registeredResources = registeredResources.ToArray(); + + public ValidateOptionsResult Validate(string? name, McpServerOptions options) + { + if (!string.IsNullOrEmpty(name)) + { + return ValidateOptionsResult.Skip; + } + + foreach (AppToolRegistration registration in _registrations) + { + string toolName = registration.Tool.ProtocolTool.Name; + if (_registeredTools.Count(tool => string.Equals( + tool.ProtocolTool.Name, + toolName, + StringComparison.Ordinal)) > 1) + { + return ValidateOptionsResult.Fail( + $"The app tool name '{toolName}' is already registered. App tool names must be unique."); + } + + AppToolRegistration? equivalentRegistration = _registrations.FirstOrDefault(candidate => + !ReferenceEquals(candidate, registration) && + !string.Equals(candidate.ResourceUri, registration.ResourceUri, StringComparison.Ordinal) && + ResourceUrisEqual(candidate.ResourceUri, registration.ResourceUri)); + if (equivalentRegistration is not null) + { + return ValidateOptionsResult.Fail( + $"The app resource URIs '{registration.ResourceUri}' and '{equivalentRegistration.ResourceUri}' " + + "identify the same resource but use different spellings. Use one exact URI for all linked tools."); + } + + foreach (McpServerResource registeredResource in _registeredResources) + { + if (ResourceUrisEqual( + registeredResource.ProtocolResourceTemplate?.UriTemplate, + registration.ResourceUri) && + !_registrations.Any(candidate => ReferenceEquals(candidate.Resource, registeredResource))) + { + return ValidateOptionsResult.Fail( + $"The app resource URI '{registration.ResourceUri}' is already registered by a lower-level resource. " + + "Each app resource URI must be owned by WithAppTool."); + } + } + + if (options.ToolCollection is null || + !options.ToolCollection.TryGetPrimitive(toolName, out McpServerTool? selectedTool) || + !ReferenceEquals(selectedTool, registration.Tool)) + { + return ValidateOptionsResult.Fail( + $"The app tool name '{toolName}' is already registered and does not resolve to this WithAppTool registration."); + } + + if (selectedTool.ProtocolTool.Meta?["ui"] is not JsonObject uiMetadata || + uiMetadata["resourceUri"] is not JsonValue resourceUriValue || + !resourceUriValue.TryGetValue(out string? selectedResourceUri) || + !string.Equals(selectedResourceUri, registration.ResourceUri, StringComparison.Ordinal)) + { + return ValidateOptionsResult.Fail( + $"The app tool '{toolName}' must link to the registered resource URI '{registration.ResourceUri}'."); + } + + if (options.ResourceCollection is null || + !options.ResourceCollection.TryGetPrimitive( + registration.ResourceUri, + out McpServerResource? selectedResource) || + selectedResource?.ProtocolResourceTemplate is not { } resourceTemplate || + !_registrations.Any(candidate => + string.Equals(candidate.ResourceUri, registration.ResourceUri, StringComparison.Ordinal) && + ReferenceEquals(candidate.Resource, selectedResource)) || + !string.Equals( + resourceTemplate.UriTemplate, + registration.ResourceUri, + StringComparison.Ordinal) || + !string.Equals( + resourceTemplate.MimeType, + McpApps.HtmlMimeType, + StringComparison.Ordinal)) + { + return ValidateOptionsResult.Fail( + $"The app tool '{toolName}' does not resolve to its HTML resource '{registration.ResourceUri}'."); + } + } + + return ValidateOptionsResult.Success; + } + + private static bool ResourceUrisEqual(string? first, string? second) + { + if (first is not null && + second is not null && + !first.Contains('{') && + !second.Contains('{') && + Uri.TryCreate(first, UriKind.Absolute, out Uri? firstUri) && + Uri.TryCreate(second, UriKind.Absolute, out Uri? secondUri)) + { + return firstUri == secondUri; + } + + return string.Equals(first, second, StringComparison.Ordinal); + } + } + /// /// 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..bd0b549e9 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs @@ -470,6 +470,389 @@ 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_RejectsNullToolUiResourceUri() + { + 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"] = null }, + }, + })); + + Assert.Equal("toolOptions", exception.ParamName); + Assert.Contains("must be a string", exception.Message); + } + + [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("toolOptions", exception.ParamName); + Assert.Contains("ui://other/view.html", exception.Message); + Assert.Contains("ui://weather/view.html", exception.Message); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task WithAppTool_RejectsLowLevelResourceCollision(bool registerLowLevelResourceFirst) + { + const string ResourceUri = "ui://shared/view.html"; + var services = new ServiceCollection(); + var builder = services.AddMcpServer(); + var lowLevelResource = McpServerResource.Create( + () => "low-level", + new() { UriTemplate = ResourceUri, MimeType = "text/plain" }); + + if (registerLowLevelResourceFirst) + { + builder.WithResources([lowLevelResource]); + } + + builder.WithAppTool( + () => "app", + ResourceUri, + () => "app", + new() { Name = "app_tool" }); + + if (!registerLowLevelResourceFirst) + { + builder.WithResources([lowLevelResource]); + } + + await using var serviceProvider = services.BuildServiceProvider(); + var exception = Assert.Throws( + () => serviceProvider.GetRequiredService>().Value); + + Assert.Contains(ResourceUri, exception.Message); + Assert.Contains("already registered", exception.Message); + } + + [Theory] + [InlineData("ui://weather/view.html", "ui://WEATHER/view.html")] + [InlineData("ui://weather/literal%7Bview%7D.html", "ui://weather/literal%7bview%7d.html")] + [InlineData("ui://weather/a/../view.html", "ui://weather/view.html")] + [InlineData("ui://weather/view.html#one", "ui://weather/view.html#two")] + [InlineData("ui://weather/%76iew.html", "ui://weather/view.html")] + public async Task WithAppTool_RejectsEquivalentResourceUrisWithDifferentSpelling( + string firstResourceUri, + string secondResourceUri) + { + var services = new ServiceCollection(); + services.AddMcpServer() + .WithAppTool(() => "first", firstResourceUri, () => "first", new() { Name = "first" }) + .WithAppTool(() => "second", secondResourceUri, () => "second", new() { Name = "second" }); + + await using var serviceProvider = services.BuildServiceProvider(); + var exception = Assert.Throws( + () => serviceProvider.GetRequiredService>().Value); + + Assert.Contains(firstResourceUri, exception.Message); + Assert.Contains(secondResourceUri, exception.Message); + Assert.Contains("same resource", exception.Message); + } + + [Fact] + public async Task WithAppTool_AllowsDistinctResourceQueries() + { + var services = new ServiceCollection(); + services.AddMcpServer() + .WithAppTool(() => "first", "ui://weather/view.html?mode=compact", () => "first", new() { Name = "first" }) + .WithAppTool(() => "second", "ui://weather/view.html?mode=full", () => "second", new() { Name = "second" }); + + await using var serviceProvider = services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + Assert.Equal(2, options.ToolCollection!.Count); + Assert.Equal(2, options.ResourceCollection!.Count); + } + + [Fact] + public async Task WithAppTool_RejectsPostConfiguredResourceLinkMismatch() + { + var services = new ServiceCollection(); + services.AddMcpServer() + .WithAppTool( + () => "result", + "ui://weather/view.html", + () => "", + new() { Name = "app_tool" }); + services.PostConfigure(options => + { + McpServerTool tool = Assert.Single(options.ToolCollection!); + tool.ProtocolTool.Meta!["ui"]!["resourceUri"] = "ui://other/view.html"; + }); + + await using var serviceProvider = services.BuildServiceProvider(); + var exception = Assert.Throws( + () => serviceProvider.GetRequiredService>().Value); + + Assert.Contains("app_tool", exception.Message); + Assert.Contains("ui://weather/view.html", exception.Message); + } + + [Fact] + public async Task WithAppTool_RejectsDuplicateToolName() + { + var services = new ServiceCollection(); + services.AddMcpServer() + .WithAppTool(() => "first", "ui://first/view.html", () => "first", new() { Name = "shared" }) + .WithAppTool(() => "second", "ui://second/view.html", () => "second", new() { Name = "shared" }); + + await using var serviceProvider = services.BuildServiceProvider(); + var exception = Assert.Throws( + () => serviceProvider.GetRequiredService>().Value); + + Assert.Contains("shared", exception.Message); + Assert.Contains("already registered", exception.Message); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task WithAppTool_RejectsLowLevelToolCollision(bool registerLowLevelToolFirst) + { + var services = new ServiceCollection(); + var builder = services.AddMcpServer(); + var lowLevelTool = McpServerTool.Create(() => "low-level", new() { Name = "shared" }); + + if (registerLowLevelToolFirst) + { + builder.WithTools([lowLevelTool]); + } + + builder.WithAppTool( + () => "app", + "ui://shared/view.html", + () => "app", + new() { Name = "shared" }); + + if (!registerLowLevelToolFirst) + { + builder.WithTools([lowLevelTool]); + } + + await using var serviceProvider = services.BuildServiceProvider(); + var exception = Assert.Throws( + () => serviceProvider.GetRequiredService>().Value); + + Assert.Contains("shared", exception.Message); + Assert.Contains("already registered", 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!)); + } + + [Fact] + public void WithAppTool_RejectsHtmlFactoryWithUnsupportedReturnType() + { + var builder = new ServiceCollection().AddMcpServer(); + Func htmlFactory = () => new(); + + var exception = Assert.Throws(() => builder.WithAppTool( + () => "result", + "ui://weather/view.html", + htmlFactory)); + + Assert.Equal("htmlFactory", exception.ParamName); + Assert.Contains("string", exception.Message); + } + + [Fact] + public void WithAppTool_RejectsHtmlFactoryWithNonCancellationParameter() + { + var builder = new ServiceCollection().AddMcpServer(); + Func htmlFactory = value => value; + + var exception = Assert.Throws(() => builder.WithAppTool( + () => "result", + "ui://weather/view.html", + htmlFactory)); + + Assert.Equal("htmlFactory", exception.ParamName); + Assert.Contains(nameof(CancellationToken), exception.Message); + } + + [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); + } + + [Fact] + public async Task WithAppTool_AcceptsEncodedBracesAsLiteralUriContent() + { + const string ResourceUri = "ui://weather/literal%7Bview%7D.html"; + var services = new ServiceCollection(); + services.AddMcpServer() + .WithAppTool( + () => "result", + ResourceUri, + () => "", + new() { Name = "app_tool" }); + + 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(ResourceUri, tool.ProtocolTool.Meta?["ui"]?["resourceUri"]?.GetValue()); + Assert.Equal(ResourceUri, resource.ProtocolResourceTemplate.UriTemplate); + Assert.False(resource.IsTemplated); + Assert.True(resource.IsMatch(ResourceUri)); + } + + #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..e00bedac9 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpAppsWithAppToolIntegrationTests.cs @@ -0,0 +1,143 @@ +#pragma warning disable MCPEXP003 + +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Extensions.Apps; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Text.Json.Nodes; + +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"; + private const string ValueTaskResourceUri = "ui://weather/details.html"; + private const string ValueTaskHtml = "weather details"; + + public McpAppsWithAppToolIntegrationTests(ITestOutputHelper testOutputHelper) + : base(testOutputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder + .WithAppTool( + AppTools.GetWeather, + AppResourceUri, + AppTools.GetHtmlAsync, + new McpServerToolCreateOptions + { + Meta = new JsonObject + { + ["ui"] = new JsonObject + { + ["resourceUri"] = AppResourceUri, + ["visibility"] = new JsonArray(McpUiToolVisibility.App), + }, + }, + }) + .WithAppTool(AppTools.GetWeatherSummary, AppResourceUri, static () => "ignored") + .WithAppTool(AppTools.GetWeatherDetails, ValueTaskResourceUri, AppTools.GetValueTaskHtmlAsync); + } + + [Fact] + public async Task WithAppTool_RoundTripsToolMetadataAndParameters() + { + await using McpClient client = await CreateMcpClientForServer(); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(3, 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.Equal(McpUiToolVisibility.App, tool.ProtocolTool.Meta?["ui"]?["visibility"]?[0]?.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); + Assert.Equal(2, resources.Count); + var resource = Assert.Single(resources, resource => resource.Uri == AppResourceUri); + Assert.Equal(AppResourceUri, resource.Uri); + Assert.Equal(McpApps.HtmlMimeType, resource.MimeType); + + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal( + 2, + tools.Count(tool => + tool.ProtocolTool.Meta?["ui"]?["resourceUri"]?.GetValue() == resource.Uri)); + + 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); + } + + [Fact] + public async Task WithAppTool_RoundTripsValueTaskHtmlHandler() + { + await using McpClient client = await CreateMcpClientForServer(); + + var result = await client.ReadResourceAsync( + ValueTaskResourceUri, + cancellationToken: TestContext.Current.CancellationToken); + + var content = Assert.IsType(Assert.Single(result.Contents)); + Assert.Equal(ValueTaskResourceUri, content.Uri); + Assert.Equal(McpApps.HtmlMimeType, content.MimeType); + Assert.Equal(ValueTaskHtml, 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"; + + [McpServerTool(Name = "weather_details")] + [Description("Gets weather details")] + public static string GetWeatherDetails() => "Weather details"; + + public static Task GetHtmlAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return Task.FromResult(AppHtml); + } + + public static ValueTask GetValueTaskHtmlAsync(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + return new(ValueTaskHtml); + } + } +}