Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions docs/concepts/apps/apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,83 @@ namespace ModelContextProtocol.Extensions.Apps;
[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)]
public static class McpAppsBuilderExtensions
{
/// <summary>
/// Registers a tool together with the HTML resource it renders.
/// </summary>
/// <param name="builder">The server builder.</param>
/// <param name="method">The tool method to expose.</param>
/// <param name="resourceUri">The <c>ui://</c> resource URI associated with the tool.</param>
/// <param name="htmlFactory">A callback that returns the HTML for the UI resource.</param>
/// <param name="toolOptions">Optional options used when creating the tool.</param>
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
/// <exception cref="ArgumentNullException">
/// <paramref name="builder"/>, <paramref name="method"/>, <paramref name="resourceUri"/>, or
/// <paramref name="htmlFactory"/> is <see langword="null"/>.
/// </exception>
/// <exception cref="ArgumentException"><paramref name="resourceUri"/> is empty or consists only of whitespace.</exception>
/// <remarks>
/// <para>
/// This is the compact equivalent of creating a tool with <see cref="McpServerTool.Create(Delegate, McpServerToolCreateOptions?)"/>,
/// applying <see cref="McpApps.SetAppUi(McpServerTool, McpUiToolMeta)"/>, and creating a resource with
/// <see cref="McpServerResource.Create(Delegate, McpServerResourceCreateOptions?)"/>. The resource is registered with
/// <see cref="McpApps.HtmlMimeType"/>, and the returned HTML is wrapped by the existing resource result conversion.
/// </para>
/// <para>
/// Calling this method also enables <see cref="WithMcpApps(IMcpServerBuilder)"/> so the server advertises MCP Apps support.
/// Existing <see cref="McpServerToolCreateOptions.Meta"/> UI metadata is preserved, as it is with
/// <see cref="McpApps.SetAppUi(McpServerTool, McpUiToolMeta)"/>.
/// </para>
/// </remarks>
/// <example>
/// <code language="csharp">
/// builder.Services
/// .AddMcpServer()
/// .WithAppTool(
/// (string location) =&gt; $&quot;Weather for {location}&quot;,
/// &quot;ui://weather/view.html&quot;,
/// () =&gt; File.ReadAllText(&quot;weather.html&quot;));
/// </code>
/// </example>
public static IMcpServerBuilder WithAppTool(
this IMcpServerBuilder builder,
Delegate method,
string resourceUri,
Func<string> 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();
}

/// <summary>
/// Enables MCP Apps support by automatically processing <see cref="McpAppUiAttribute"/> on registered tools.
/// </summary>
Expand Down
86 changes: 86 additions & 0 deletions tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
() => "<html>weather</html>",
new McpServerToolCreateOptions
{
Name = "weather",
Description = "Gets weather",
Meta = new JsonObject { ["custom"] = "value" },
});

await using var serviceProvider = services.BuildServiceProvider();
var options = serviceProvider.GetRequiredService<IOptions<McpServerOptions>>().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<string>());
Assert.Equal("ui://weather/view.html", tool.ProtocolTool.Meta?["ui"]?["resourceUri"]?.GetValue<string>());
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",
() => "<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<IOptions<McpServerOptions>>().Value.ToolCollection!);

Assert.Equal("ui://explicit/view.html", tool.ProtocolTool.Meta?["ui"]?["resourceUri"]?.GetValue<string>());
}

[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<IOptions<McpServerOptions>>().Value;

Assert.Equal(2, options.ToolCollection!.Count);
Assert.Single(options.ResourceCollection!);
}

[Fact]
public void WithAppTool_RejectsMissingConfiguration()
{
var builder = new ServiceCollection().AddMcpServer();
Func<string> htmlFactory = () => "html";
Delegate method = () => "result";

Assert.Throws<ArgumentNullException>(() => builder.WithAppTool(null!, "ui://test", htmlFactory));
Assert.Throws<ArgumentNullException>(() => builder.WithAppTool(method, null!, htmlFactory));
Assert.Throws<ArgumentNullException>(() => builder.WithAppTool(method, "ui://test", null!));
Assert.Throws<ArgumentException>(() => builder.WithAppTool(method, " ", htmlFactory));
}

#endregion

#region Test helper types

[McpServerToolType]
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Verifies that <see cref="McpAppsBuilderExtensions.WithAppTool"/> preserves the
/// tool and resource behavior across a client/server round trip.
/// </summary>
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 () => "<html><body>weather</body></html>");
}

[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<string>());
Assert.Contains("location", tool.ProtocolTool.InputSchema.GetProperty("properties").EnumerateObject().Select(p => p.Name));

var result = await client.CallToolAsync(
"weather",
new Dictionary<string, object?> { ["location"] = "Paris" },
cancellationToken: TestContext.Current.CancellationToken);

var text = Assert.IsType<TextContentBlock>(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<TextResourceContents>(Assert.Single(result.Contents));
Assert.Equal(resource.Uri, content.Uri);
Assert.Equal(McpApps.HtmlMimeType, content.MimeType);
Assert.Equal("<html><body>weather</body></html>", content.Text);
}

private static class AppTools
{
[McpServerTool(Name = "weather")]
[Description("Gets weather for a location")]
public static string GetWeather(string location) => $"Weather for {location}";
}
}