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
23 changes: 21 additions & 2 deletions docs/concepts/apps/apps.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 be a string that exactly matches 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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.Diagnostics.CodeAnalysis;
using System.Text.Json.Nodes;

namespace ModelContextProtocol.Extensions.Apps;

Expand All @@ -12,6 +13,121 @@ 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 absolute <c>ui://</c> resource URI associated with the tool.</param>
/// <param name="htmlFactory">A resource handler that returns the HTML, synchronously or asynchronously.</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 not an absolute, non-templated <c>ui://</c> URI, or the tool's existing
/// <c>_meta.ui.resourceUri</c> value is not a string that exactly matches it.
/// </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. If it already contains
/// <c>ui.resourceUri</c>, that value must be a string that exactly matches <paramref name="resourceUri"/>.
/// </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,
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.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(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();
}

/// <summary>
/// Enables MCP Apps support by automatically processing <see cref="McpAppUiAttribute"/> on registered tools.
/// </summary>
Expand Down
205 changes: 205 additions & 0 deletions tests/ModelContextProtocol.Tests/Server/McpAppsTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -470,6 +470,211 @@ 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_PreservesMatchingToolUiMetadata()
{
var services = new ServiceCollection();
services.AddMcpServer()
.WithAppTool(
() => "result",
"ui://explicit/view.html",
() => "<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<IOptions<McpServerOptions>>().Value.ToolCollection!);

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

[Fact]
public async Task WithAppTool_AddsResourceUriToExistingToolUiMetadata()
{
var services = new ServiceCollection();
services.AddMcpServer()
.WithAppTool(
() => "result",
"ui://weather/view.html",
() => "<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<IOptions<McpServerOptions>>().Value.ToolCollection!);

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

[Fact]
public void WithAppTool_RejectsNullToolUiResourceUri()
{
var builder = new ServiceCollection().AddMcpServer();

var exception = Assert.Throws<ArgumentException>(() => builder.WithAppTool(
() => "result",
"ui://weather/view.html",
() => "<html />",
new McpServerToolCreateOptions
{
Name = "app_tool",
Meta = new JsonObject
{
["ui"] = new JsonObject { ["resourceUri"] = null },
},
}));

Assert.Equal("resourceUri", exception.ParamName);
Assert.Contains("must be a string", exception.Message);
}

[Fact]
public void WithAppTool_RejectsConflictingToolUiMetadata()
{
var builder = new ServiceCollection().AddMcpServer();

var exception = Assert.Throws<ArgumentException>(() => builder.WithAppTool(
() => "result",
"ui://weather/view.html",
() => "<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<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!));
}

[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<string> htmlFactory = () => "html";

var exception = Assert.Throws<ArgumentException>(() => 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,
() => "<html />",
new() { Name = "app_tool" });

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(ResourceUri, tool.ProtocolTool.Meta?["ui"]?["resourceUri"]?.GetValue<string>());
Assert.Equal(ResourceUri, resource.ProtocolResourceTemplate.UriTemplate);
Assert.False(resource.IsTemplated);
Assert.True(resource.IsMatch(ResourceUri));
}

#endregion

#region Test helper types

[McpServerToolType]
Expand Down
Loading