Add MCP Apps extension support (typed metadata, attribute, and helpers) (#1484)
This commit is contained in:
parent
de75c7d0df
commit
f93df7e172
27 changed files with 1896 additions and 1 deletions
|
|
@ -51,6 +51,7 @@
|
|||
<Project Path="samples/QuickstartWeatherServer/QuickstartWeatherServer.csproj" />
|
||||
<Project Path="samples/TasksExtension/TasksExtension.csproj" />
|
||||
<Project Path="samples/TestServerWithHosting/TestServerWithHosting.csproj" />
|
||||
<Project Path="samples/WeatherAppServer/WeatherAppServer.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/Solution Items/">
|
||||
<File Path="Directory.Build.props" />
|
||||
|
|
@ -67,6 +68,7 @@
|
|||
<Project Path="src/ModelContextProtocol.Analyzers/ModelContextProtocol.Analyzers.csproj" />
|
||||
<Project Path="src/ModelContextProtocol.AspNetCore/ModelContextProtocol.AspNetCore.csproj" />
|
||||
<Project Path="src/ModelContextProtocol.Core/ModelContextProtocol.Core.csproj" />
|
||||
<Project Path="src/ModelContextProtocol.Extensions.Apps/ModelContextProtocol.Extensions.Apps.csproj" />
|
||||
<Project Path="src/ModelContextProtocol/ModelContextProtocol.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
|
|
|
|||
191
docs/concepts/apps/apps.md
Normal file
191
docs/concepts/apps/apps.md
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
---
|
||||
title: MCP Apps
|
||||
author: mikekistler
|
||||
description: How to use the MCP Apps extension to deliver interactive UIs from MCP servers.
|
||||
uid: apps
|
||||
---
|
||||
|
||||
# MCP Apps
|
||||
|
||||
[MCP Apps] is an extension to the Model Context Protocol that enables MCP servers to deliver interactive user interfaces — dashboards, forms, visualizations, and more — directly inside conversational AI clients.
|
||||
|
||||
[MCP Apps]: https://modelcontextprotocol.io/extensions/apps/overview
|
||||
|
||||
> [!IMPORTANT]
|
||||
> MCP Apps support is experimental. All types are marked with `[Experimental("MCPEXP003")]` and require suppressing that diagnostic to use.
|
||||
|
||||
## Installation
|
||||
|
||||
MCP Apps is provided in the `ModelContextProtocol.Extensions.Apps` package, which layers on top of the core SDK:
|
||||
|
||||
```shell
|
||||
dotnet add package ModelContextProtocol.Extensions.Apps
|
||||
```
|
||||
|
||||
## Overview
|
||||
|
||||
The MCP Apps extension introduces the concept of **UI resources** — HTML pages served by the MCP server that a client can display alongside the conversation. Tools can be associated with a UI resource so the client knows which interface to show when a tool is called.
|
||||
|
||||
The key concepts are:
|
||||
|
||||
- **UI capability negotiation** — Client and server declare support via `extensions["io.modelcontextprotocol/ui"]`
|
||||
- **UI resources** — HTML content served with the MIME type `text/html;profile=mcp-app`
|
||||
- **Tool UI metadata** — Tools declare their associated UI resource in `_meta.ui`
|
||||
|
||||
## Associating tools with UI resources
|
||||
|
||||
### Using the builder extension (recommended)
|
||||
|
||||
The simplest approach is to apply `[McpAppUi]` attributes to your tool methods and call `WithMcpApps()` on the server builder:
|
||||
|
||||
```csharp
|
||||
[McpServerToolType]
|
||||
public class WeatherTools
|
||||
{
|
||||
[McpServerTool, Description("Get current weather for a location")]
|
||||
[McpAppUi(ResourceUri = "ui://weather/view.html")]
|
||||
public static string GetWeather(string location) => $"Weather for {location}";
|
||||
|
||||
[McpServerTool, Description("Get forecast (model-only tool)")]
|
||||
[McpAppUi(ResourceUri = "ui://weather/forecast.html", Visibility = [McpUiToolVisibility.Model])]
|
||||
public static string GetForecast(string location) => $"Forecast for {location}";
|
||||
}
|
||||
```
|
||||
|
||||
```csharp
|
||||
builder.Services.AddMcpServer()
|
||||
.WithTools<WeatherTools>()
|
||||
.WithMcpApps();
|
||||
```
|
||||
|
||||
The `WithMcpApps()` call registers a post-configuration step that processes all registered tools and applies `[McpAppUi]` attribute metadata to their `_meta.ui` field automatically.
|
||||
|
||||
### Using the attribute with manual processing
|
||||
|
||||
If you create tools manually (without `WithMcpApps()`), you can still use the attribute and process tools explicitly:
|
||||
|
||||
```csharp
|
||||
var tools = new[]
|
||||
{
|
||||
McpServerTool.Create(typeof(WeatherTools).GetMethod(nameof(WeatherTools.GetWeather))!),
|
||||
McpServerTool.Create(typeof(WeatherTools).GetMethod(nameof(WeatherTools.GetForecast))!),
|
||||
};
|
||||
|
||||
McpApps.ApplyAppUiAttributes(tools);
|
||||
```
|
||||
|
||||
### Using the programmatic API
|
||||
|
||||
For full control, use `McpApps.SetAppUi` to set UI metadata directly:
|
||||
|
||||
```csharp
|
||||
var tool = McpServerTool.Create((string location) => $"Weather for {location}");
|
||||
|
||||
McpApps.SetAppUi(tool, new McpUiToolMeta
|
||||
{
|
||||
ResourceUri = "ui://weather/view.html",
|
||||
Visibility = [McpUiToolVisibility.Model, McpUiToolVisibility.App],
|
||||
});
|
||||
```
|
||||
|
||||
## Checking client capabilities
|
||||
|
||||
During a session, you can check whether the connected client supports MCP Apps:
|
||||
|
||||
```csharp
|
||||
[McpServerTool, Description("Get weather")]
|
||||
[McpAppUi(ResourceUri = "ui://weather/view.html")]
|
||||
public static string GetWeather(McpServer server, string location)
|
||||
{
|
||||
var uiCapability = McpApps.GetUiCapability(server.ClientCapabilities);
|
||||
if (uiCapability is not null)
|
||||
{
|
||||
// Client supports MCP Apps — the UI will be displayed
|
||||
}
|
||||
|
||||
return $"Weather for {location}";
|
||||
}
|
||||
```
|
||||
|
||||
## Tool visibility
|
||||
|
||||
The `Visibility` property controls which principals can invoke the tool:
|
||||
|
||||
| Value | Meaning |
|
||||
| - | - |
|
||||
| `McpUiToolVisibility.Model` | Only the LLM can call this tool |
|
||||
| `McpUiToolVisibility.App` | Only the app UI can call this tool |
|
||||
| Both (or null/empty) | Both the model and app can call the tool (default) |
|
||||
|
||||
## UI resources
|
||||
|
||||
UI resources are HTML pages registered with the MCP server using the `ui://` URI scheme and the `text/html;profile=mcp-app` MIME type. The `McpUiResourceMeta` type provides metadata for these resources, including:
|
||||
|
||||
- **CSP (Content Security Policy)** — Controls allowed origins for network requests and resource loads
|
||||
- **Permissions** — Sandbox permissions (scripts, forms, popups, etc.)
|
||||
- **Domain** — Dedicated origin for OAuth flows and CORS
|
||||
- **PrefersBorder** — Whether the host should render a visual border
|
||||
|
||||
## App-only tools
|
||||
|
||||
Tools with `Visibility = [McpUiToolVisibility.App]` are not visible to the LLM — they are intended only for use by the app UI.
|
||||
This is useful for tools that serve UI interaction (button handlers, form submissions) without cluttering the model's tool list:
|
||||
|
||||
```csharp
|
||||
[McpServerTool, Description("Submit the weather form")]
|
||||
[McpAppUi(ResourceUri = "ui://weather/view.html", Visibility = [McpUiToolVisibility.App])]
|
||||
public static string SubmitWeatherForm(string city) => GetWeatherHtml(city);
|
||||
```
|
||||
|
||||
## Graceful degradation
|
||||
|
||||
Not all clients support MCP Apps. Use `GetUiCapability` to detect support and return text-only content as a fallback:
|
||||
|
||||
```csharp
|
||||
[McpServerTool, Description("Get weather")]
|
||||
[McpAppUi(ResourceUri = "ui://weather/view.html")]
|
||||
public static string GetWeather(McpServer server, string location)
|
||||
{
|
||||
var uiCapability = McpApps.GetUiCapability(server.ClientCapabilities);
|
||||
if (uiCapability is null)
|
||||
{
|
||||
// Client doesn't support MCP Apps — return plain text
|
||||
return $"Current weather for {location}: 72°F, sunny";
|
||||
}
|
||||
|
||||
// Client supports MCP Apps — the UI resource will be displayed
|
||||
return $"Weather data for {location} loaded into UI";
|
||||
}
|
||||
```
|
||||
|
||||
## Display modes
|
||||
|
||||
The MCP Apps spec defines display modes (`inline`, `fullscreen`, `pip`) that control how the host renders the UI. Display mode is negotiated between the client and server during capability exchange and is not set per-tool — it depends on the host implementation.
|
||||
|
||||
## Host theming
|
||||
|
||||
Hosts pass standardized CSS custom properties (e.g., `--color-background-primary`, `--color-text-primary`) to app iframes. Your HTML can reference these variables to automatically match the host's theme without any server-side configuration.
|
||||
|
||||
See the [MCP Apps specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx) for the full list of CSS variables.
|
||||
|
||||
## Single-file HTML bundling
|
||||
|
||||
The default Content Security Policy restricts external script and style loads. For production apps, bundle all JavaScript and CSS into a single HTML file using tools like [vite-plugin-singlefile](https://github.com/richardtallent/vite-plugin-singlefile). For simple apps, inline `<script>` and `<style>` tags work directly.
|
||||
|
||||
## Constants
|
||||
|
||||
The <xref:ModelContextProtocol.Extensions.Apps.McpApps> class provides constants for protocol values:
|
||||
|
||||
| Constant | Value | Usage |
|
||||
| - | - | - |
|
||||
| `McpApps.HtmlMimeType` | `text/html;profile=mcp-app` | MIME type for UI resources |
|
||||
| `McpApps.ExtensionId` | `io.modelcontextprotocol/ui` | Key in `extensions` capability dictionary |
|
||||
|
||||
## Serialization
|
||||
|
||||
MCP Apps types use source-generated JSON serialization for Native AOT compatibility. Use `McpApps.SerializerOptions` when serializing extension types:
|
||||
|
||||
```csharp
|
||||
var json = JsonSerializer.Serialize(toolMeta, McpApps.SerializerOptions);
|
||||
var deserialized = JsonSerializer.Deserialize<McpUiToolMeta>(json, McpApps.SerializerOptions);
|
||||
```
|
||||
|
|
@ -41,4 +41,10 @@ Install the SDK and build your first MCP client and server.
|
|||
| [Stateless and Stateful](stateless/stateless.md) | Learn when to use stateless vs. stateful mode for HTTP servers and how to configure sessions. |
|
||||
| [HTTP Context](httpcontext/httpcontext.md) | Learn how to access the underlying `HttpContext` for a request. |
|
||||
| [MCP Server Handler Filters](filters.md) | Learn how to add filters to the handler pipeline. Filters let you wrap the original handler with additional functionality. |
|
||||
|
||||
### Extensions
|
||||
|
||||
| Title | Description |
|
||||
| - | - |
|
||||
| [MCP Apps](apps/apps.md) | Learn how to use the MCP Apps extension to deliver interactive UIs from MCP servers. |
|
||||
| [Identity and Roles](identity/identity.md) | Learn how to access caller identity and roles in MCP tool, prompt, and resource handlers. |
|
||||
|
|
|
|||
|
|
@ -47,5 +47,9 @@ items:
|
|||
uid: httpcontext
|
||||
- name: Filters
|
||||
uid: filters
|
||||
- name: Extensions
|
||||
items:
|
||||
- name: MCP Apps
|
||||
uid: apps
|
||||
- name: Identity and Roles
|
||||
uid: identity
|
||||
uid: identity
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ If you use experimental APIs, you will get one of the diagnostics shown below. T
|
|||
| :------------ | :---------- |
|
||||
| `MCPEXP001` | Experimental APIs for features in the MCP specification itself, including Tasks and Extensions. Tasks provide a mechanism for asynchronous long-running operations that can be polled for status and results (see [SEP-2663](https://github.com/modelcontextprotocol/modelcontextprotocol/blob/main/seps/2663-tasks-extension.md)). Extensions provide a framework for extending the Model Context Protocol while maintaining interoperability (see [SEP-2133](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/2133)). |
|
||||
| `MCPEXP002` | Experimental SDK APIs unrelated to the MCP specification itself, including subclassing `McpClient`/`McpServer` (see [#1363](https://github.com/modelcontextprotocol/csharp-sdk/pull/1363)) and `RunSessionHandler`, which may be removed or change signatures in a future release (consider using `ConfigureSessionOptions` instead). |
|
||||
| `MCPEXP003` | Experimental MCP Apps extension APIs. MCP Apps is the first official MCP extension (`io.modelcontextprotocol/ui`), enabling servers to deliver interactive UIs inside AI clients (see [MCP Apps specification](https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx)). |
|
||||
|
||||
## Obsolete APIs
|
||||
|
||||
|
|
|
|||
34
samples/WeatherAppServer/Program.cs
Normal file
34
samples/WeatherAppServer/Program.cs
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using ModelContextProtocol.AspNetCore;
|
||||
using ModelContextProtocol.Extensions.Apps;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.Net.Http.Headers;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddSingleton(_ =>
|
||||
{
|
||||
var client = new HttpClient { BaseAddress = new Uri("https://api.weather.gov") };
|
||||
client.DefaultRequestHeaders.UserAgent.Add(new ProductInfoHeaderValue("WeatherAppServer", "1.0"));
|
||||
return client;
|
||||
});
|
||||
|
||||
builder.Services
|
||||
.AddMcpServer(options =>
|
||||
{
|
||||
options.ServerInfo = new Implementation { Name = "weather-app-server", Version = "1.0.0" };
|
||||
options.Capabilities = new ServerCapabilities
|
||||
{
|
||||
Tools = new ToolsCapability(),
|
||||
Resources = new ResourcesCapability(),
|
||||
};
|
||||
})
|
||||
.WithHttpTransport()
|
||||
.WithTools<WeatherTools>()
|
||||
.WithResources<WeatherResources>()
|
||||
.WithMcpApps();
|
||||
|
||||
var app = builder.Build();
|
||||
app.MapMcp("/mcp");
|
||||
app.Run();
|
||||
41
samples/WeatherAppServer/README.md
Normal file
41
samples/WeatherAppServer/README.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Weather App Server
|
||||
|
||||
An MCP server that demonstrates the **MCP Apps** extension by serving an interactive weather forecast UI alongside weather tools.
|
||||
|
||||
## What it shows
|
||||
|
||||
- **`[McpAppUi]` attribute** — declaratively associates a UI resource with a tool
|
||||
- **`WithMcpApps()`** — builder extension that processes `[McpAppUi]` attributes
|
||||
- **UI resource** — an HTML page served via `McpServerResource` with MIME type `text/html;profile=mcp-app`
|
||||
- **Structured content** — tool results include `StructuredContent` for the UI to render
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
dotnet run
|
||||
```
|
||||
|
||||
The server starts on `http://localhost:5000` by default. Connect any MCP Apps-capable client to the `/mcp` endpoint.
|
||||
|
||||
Then prompt that will cause the LLM to request the use of the "weather_ui" tool.
|
||||
A general prompt like "What's the weather?" will probably work, but if not you could try explicitly requesting the tool
|
||||
with something like "@weather_ui". This should load the Weather App UI in an iFrame that you can then interact with
|
||||
to get the weather forecast for a number of US cities.
|
||||
|
||||

|
||||
|
||||
## Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `weather_ui` | Opens the weather forecast UI |
|
||||
| `weather_forecast` | Gets a multi-period forecast from the National Weather Service for a US city |
|
||||
|
||||
Both tools are linked to the `ui://weather-app/forecast` resource via the `[McpAppUi]` attribute.
|
||||
|
||||
## Resources
|
||||
|
||||
| URI | Description |
|
||||
|-----|-------------|
|
||||
| `ui://weather-app/forecast` | Interactive weather forecast HTML UI |
|
||||
| `data://weather-app/us-cities` | JSON list of supported US cities |
|
||||
64
samples/WeatherAppServer/UsCity.cs
Normal file
64
samples/WeatherAppServer/UsCity.cs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
using System.Text.Json.Serialization;
|
||||
|
||||
[JsonConverter(typeof(JsonStringEnumConverter<UsCity>))]
|
||||
public enum UsCity
|
||||
{
|
||||
[JsonStringEnumMemberName("Albuquerque, NM")] AlbuquerqueNM,
|
||||
[JsonStringEnumMemberName("Atlanta, GA")] AtlantaGA,
|
||||
[JsonStringEnumMemberName("Austin, TX")] AustinTX,
|
||||
[JsonStringEnumMemberName("Boston, MA")] BostonMA,
|
||||
[JsonStringEnumMemberName("Charlotte, NC")] CharlotteNC,
|
||||
[JsonStringEnumMemberName("Chicago, IL")] ChicagoIL,
|
||||
[JsonStringEnumMemberName("Dallas, TX")] DallasTX,
|
||||
[JsonStringEnumMemberName("Denver, CO")] DenverCO,
|
||||
[JsonStringEnumMemberName("Houston, TX")] HoustonTX,
|
||||
[JsonStringEnumMemberName("Indianapolis, IN")] IndianapolisIN,
|
||||
[JsonStringEnumMemberName("Las Vegas, NV")] LasVegasNV,
|
||||
[JsonStringEnumMemberName("Los Angeles, CA")] LosAngelesCA,
|
||||
[JsonStringEnumMemberName("Miami, FL")] MiamiFL,
|
||||
[JsonStringEnumMemberName("Minneapolis, MN")] MinneapolisMN,
|
||||
[JsonStringEnumMemberName("Nashville, TN")] NashvilleTN,
|
||||
[JsonStringEnumMemberName("New York, NY")] NewYorkNY,
|
||||
[JsonStringEnumMemberName("Orlando, FL")] OrlandoFL,
|
||||
[JsonStringEnumMemberName("Philadelphia, PA")] PhiladelphiaPA,
|
||||
[JsonStringEnumMemberName("Phoenix, AZ")] PhoenixAZ,
|
||||
[JsonStringEnumMemberName("Portland, OR")] PortlandOR,
|
||||
[JsonStringEnumMemberName("Salt Lake City, UT")] SaltLakeCityUT,
|
||||
[JsonStringEnumMemberName("San Diego, CA")] SanDiegoCA,
|
||||
[JsonStringEnumMemberName("San Francisco, CA")] SanFranciscoCA,
|
||||
[JsonStringEnumMemberName("Seattle, WA")] SeattleWA,
|
||||
[JsonStringEnumMemberName("Washington, DC")] WashingtonDC,
|
||||
}
|
||||
|
||||
public static class UsCityData
|
||||
{
|
||||
public static (double Latitude, double Longitude) GetCoordinates(UsCity city) => city switch
|
||||
{
|
||||
UsCity.AlbuquerqueNM => (35.0844, -106.6504),
|
||||
UsCity.AtlantaGA => (33.7490, -84.3880),
|
||||
UsCity.AustinTX => (30.2672, -97.7431),
|
||||
UsCity.BostonMA => (42.3601, -71.0589),
|
||||
UsCity.CharlotteNC => (35.2271, -80.8431),
|
||||
UsCity.ChicagoIL => (41.8781, -87.6298),
|
||||
UsCity.DallasTX => (32.7767, -96.7970),
|
||||
UsCity.DenverCO => (39.7392, -104.9903),
|
||||
UsCity.HoustonTX => (29.7604, -95.3698),
|
||||
UsCity.IndianapolisIN => (39.7684, -86.1581),
|
||||
UsCity.LasVegasNV => (36.1699, -115.1398),
|
||||
UsCity.LosAngelesCA => (34.0522, -118.2437),
|
||||
UsCity.MiamiFL => (25.7617, -80.1918),
|
||||
UsCity.MinneapolisMN => (44.9778, -93.2650),
|
||||
UsCity.NashvilleTN => (36.1627, -86.7816),
|
||||
UsCity.NewYorkNY => (40.7128, -74.0060),
|
||||
UsCity.OrlandoFL => (28.5383, -81.3792),
|
||||
UsCity.PhiladelphiaPA => (39.9526, -75.1652),
|
||||
UsCity.PhoenixAZ => (33.4484, -112.0740),
|
||||
UsCity.PortlandOR => (45.5152, -122.6784),
|
||||
UsCity.SaltLakeCityUT => (40.7608, -111.8910),
|
||||
UsCity.SanDiegoCA => (32.7157, -117.1611),
|
||||
UsCity.SanFranciscoCA => (37.7749, -122.4194),
|
||||
UsCity.SeattleWA => (47.6062, -122.3321),
|
||||
UsCity.WashingtonDC => (38.9072, -77.0369),
|
||||
_ => throw new ArgumentOutOfRangeException(nameof(city))
|
||||
};
|
||||
}
|
||||
20
samples/WeatherAppServer/WeatherAppServer.csproj
Normal file
20
samples/WeatherAppServer/WeatherAppServer.csproj
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<!-- Suppress experimental MCP Apps warning -->
|
||||
<NoWarn>$(NoWarn);MCPEXP003</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\ModelContextProtocol.AspNetCore\ModelContextProtocol.AspNetCore.csproj" />
|
||||
<ProjectReference Include="..\..\src\ModelContextProtocol.Extensions.Apps\ModelContextProtocol.Extensions.Apps.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Content Include="ui\*.html" CopyToOutputDirectory="PreserveNewest" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
25
samples/WeatherAppServer/WeatherResources.cs
Normal file
25
samples/WeatherAppServer/WeatherResources.cs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
using ModelContextProtocol.Extensions.Apps;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.ComponentModel;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
[McpServerResourceType]
|
||||
public sealed class WeatherResources
|
||||
{
|
||||
private static readonly string UiDir = Path.Combine(AppContext.BaseDirectory, "ui");
|
||||
|
||||
[McpServerResource(UriTemplate = "ui://weather-app/forecast", Name = "weather-forecast-ui", MimeType = McpApps.HtmlMimeType)]
|
||||
[McpMeta("ui", """{"csp":{"connectDomains":["https://api.weather.gov"]},"prefersBorder":true}""")]
|
||||
[Description("Interactive weather forecast UI with city picker")]
|
||||
public static string GetWeatherForecastUi() => File.ReadAllText(Path.Combine(UiDir, "weather-forecast.html"));
|
||||
|
||||
[McpServerResource(UriTemplate = "data://weather-app/us-cities", Name = "us-cities", MimeType = "application/json")]
|
||||
[Description("List of supported US cities for weather forecasts")]
|
||||
public static string GetUsCities()
|
||||
{
|
||||
var options = new JsonSerializerOptions { Converters = { new JsonStringEnumConverter<UsCity>() } };
|
||||
var cities = Enum.GetValues<UsCity>().Select(c => JsonSerializer.Serialize(c, options).Trim('"')).Order().ToList();
|
||||
return JsonSerializer.Serialize(cities);
|
||||
}
|
||||
}
|
||||
64
samples/WeatherAppServer/WeatherTools.cs
Normal file
64
samples/WeatherAppServer/WeatherTools.cs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
using ModelContextProtocol;
|
||||
using ModelContextProtocol.Extensions.Apps;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.ComponentModel;
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
|
||||
[McpServerToolType]
|
||||
public sealed class WeatherTools
|
||||
{
|
||||
[McpServerTool(Name = "weather_ui")]
|
||||
[McpAppUi(ResourceUri = "ui://weather-app/forecast")]
|
||||
[Description("Display an interactive weather forecast UI with city picker.")]
|
||||
public static string WeatherUi() => "Showing weather forecast UI.";
|
||||
|
||||
[McpServerTool(Name = "weather_forecast")]
|
||||
[McpAppUi(ResourceUri = "ui://weather-app/forecast")]
|
||||
[Description("Get weather forecast for a US city. Returns detailed multi-period forecast from the National Weather Service.")]
|
||||
public static async Task<CallToolResult> WeatherForecast(
|
||||
HttpClient client,
|
||||
[Description("US city to get the forecast for")] UsCity cityState)
|
||||
{
|
||||
var (latitude, longitude) = UsCityData.GetCoordinates(cityState);
|
||||
var pointUrl = string.Create(CultureInfo.InvariantCulture, $"/points/{latitude},{longitude}");
|
||||
|
||||
using var locationResponse = await client.GetAsync(pointUrl);
|
||||
locationResponse.EnsureSuccessStatusCode();
|
||||
using var locationDocument = await JsonDocument.ParseAsync(await locationResponse.Content.ReadAsStreamAsync());
|
||||
|
||||
var forecastUrl = locationDocument.RootElement.GetProperty("properties").GetProperty("forecast").GetString()
|
||||
?? throw new McpException($"No forecast URL provided by weather.gov for {cityState}");
|
||||
|
||||
using var forecastResponse = await client.GetAsync(forecastUrl);
|
||||
forecastResponse.EnsureSuccessStatusCode();
|
||||
using var forecastDocument = await JsonDocument.ParseAsync(await forecastResponse.Content.ReadAsStreamAsync());
|
||||
|
||||
var periods = forecastDocument.RootElement.GetProperty("properties").GetProperty("periods").EnumerateArray().ToList();
|
||||
|
||||
var structuredPeriods = periods.Select(period => new
|
||||
{
|
||||
name = period.GetProperty("name").GetString(),
|
||||
temperature = period.GetProperty("temperature").GetInt32(),
|
||||
temperatureUnit = period.GetProperty("temperatureUnit").GetString(),
|
||||
windSpeed = period.GetProperty("windSpeed").GetString(),
|
||||
windDirection = period.GetProperty("windDirection").GetString(),
|
||||
shortForecast = period.GetProperty("shortForecast").GetString(),
|
||||
detailedForecast = period.GetProperty("detailedForecast").GetString(),
|
||||
isDaytime = period.GetProperty("isDaytime").GetBoolean()
|
||||
}).ToList();
|
||||
|
||||
return new CallToolResult
|
||||
{
|
||||
Content = [new TextContentBlock { Text = $"Weather forecast for {cityState}." }],
|
||||
StructuredContent = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
cityState = cityState.ToString(),
|
||||
latitude,
|
||||
longitude,
|
||||
periods = structuredPeriods
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
BIN
samples/WeatherAppServer/WeatherUI.png
Normal file
BIN
samples/WeatherAppServer/WeatherUI.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 359 KiB |
257
samples/WeatherAppServer/ui/weather-forecast.html
Normal file
257
samples/WeatherAppServer/ui/weather-forecast.html
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Weather Forecast</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #1e1e1e; --surface: #252526; --surface2: #2d2d30;
|
||||
--border: #3c3c3c; --text: #cccccc; --muted: #858585;
|
||||
--accent: #0078d4; --success: #4ec9b0; --warning: #ddb76f; --danger: #f14c4c;
|
||||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
--mono: 'SF Mono', Consolas, monospace;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root { --bg: #ffffff; --surface: #f8f8f8; --surface2: #f0f0f0; --border: #e0e0e0; --text: #1e1e1e; --muted: #666666; }
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font: 12px/1.5 var(--font); background: var(--bg); color: var(--text); padding: 12px; display: flex; flex-direction: column; gap: 10px; }
|
||||
.header { display: flex; align-items: center; gap: 10px; }
|
||||
.title { font-size: 14px; font-weight: 600; display: flex; align-items: center; gap: 6px; }
|
||||
.controls { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; padding: 10px; background: var(--surface); border-radius: 6px; }
|
||||
.control-group { display: flex; flex-direction: column; gap: 3px; flex: 1; min-width: 200px; }
|
||||
.control-label { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
select { padding: 6px 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 4px; color: var(--text); font: inherit; width: 100%; }
|
||||
select:focus { outline: none; border-color: var(--accent); }
|
||||
button { padding: 6px 14px; font: 11px var(--font); border: none; border-radius: 4px; cursor: pointer; display: flex; align-items: center; gap: 4px; white-space: nowrap; }
|
||||
.btn-primary { background: var(--accent); color: white; align-self: flex-end; }
|
||||
.btn-primary:hover { background: #1c8ae6; }
|
||||
.btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||
.forecast-container { flex: 1; overflow: auto; }
|
||||
.forecast-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 8px; }
|
||||
.period-card { background: var(--surface); border: 1px solid var(--border); border-radius: 6px; padding: 10px; display: flex; flex-direction: column; gap: 6px; }
|
||||
.period-card.night { opacity: 0.8; }
|
||||
.period-name { font-size: 11px; font-weight: 600; color: var(--accent); }
|
||||
.period-temp { font-size: 22px; font-weight: 700; font-family: var(--mono); }
|
||||
.period-temp.warm { color: var(--warning); }
|
||||
.period-temp.cool { color: var(--accent); }
|
||||
.period-temp.cold { color: #7cb3f1; }
|
||||
.period-temp.hot { color: var(--danger); }
|
||||
.period-wind { font-size: 10px; color: var(--muted); }
|
||||
.period-forecast { font-size: 11px; line-height: 1.4; }
|
||||
.period-short { font-size: 11px; font-weight: 500; }
|
||||
.status-bar { font-size: 10px; color: var(--muted); padding-top: 4px; }
|
||||
.status-bar.error { color: var(--danger); }
|
||||
.status-bar.success { color: var(--success); }
|
||||
.empty { padding: 40px; text-align: center; color: var(--muted); }
|
||||
.empty-icon { font-size: 32px; margin-bottom: 8px; }
|
||||
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid var(--border); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.location-info { font-size: 11px; color: var(--muted); margin-left: auto; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<span class="title">🌤️ Weather Forecast</span>
|
||||
<span class="location-info" id="locationInfo"></span>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<div class="control-group">
|
||||
<span class="control-label">City</span>
|
||||
<select id="citySelect">
|
||||
<option value="">Select a city...</option>
|
||||
</select>
|
||||
</div>
|
||||
<button class="btn-primary" id="fetchBtn" onclick="fetchForecast()" disabled>Get Forecast</button>
|
||||
</div>
|
||||
<div class="forecast-container" id="forecastContainer">
|
||||
<div class="empty"><div class="empty-icon">🌎</div>Select a city to view the weather forecast</div>
|
||||
</div>
|
||||
<div class="status-bar" id="statusBar">Ready</div>
|
||||
|
||||
<script>
|
||||
let cities = [];
|
||||
|
||||
const citySelect = document.getElementById('citySelect');
|
||||
citySelect.addEventListener('change', () => {
|
||||
document.getElementById('fetchBtn').disabled = citySelect.value === '';
|
||||
});
|
||||
|
||||
let nextRequestId = 1;
|
||||
const pendingRequests = new Map();
|
||||
function sendRequest(method, params) {
|
||||
const id = nextRequestId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingRequests.set(id, { resolve, reject });
|
||||
window.parent.postMessage({ jsonrpc: '2.0', id, method, params: params || {} }, '*');
|
||||
});
|
||||
}
|
||||
function sendNotification(method, params) {
|
||||
window.parent.postMessage({ jsonrpc: '2.0', method, params: params || {} }, '*');
|
||||
}
|
||||
|
||||
window.addEventListener('message', (event) => {
|
||||
const msg = event.data;
|
||||
if (!msg || msg.jsonrpc !== '2.0') return;
|
||||
if (msg.id !== undefined && pendingRequests.has(msg.id)) {
|
||||
const { resolve, reject } = pendingRequests.get(msg.id);
|
||||
pendingRequests.delete(msg.id);
|
||||
msg.error ? reject(new Error(msg.error.message || JSON.stringify(msg.error))) : resolve(msg.result);
|
||||
return;
|
||||
}
|
||||
if (msg.method === 'ui/notifications/tool-input') {
|
||||
handleToolInput(msg.params);
|
||||
}
|
||||
if (msg.method === 'ui/notifications/tool-result') {
|
||||
handleToolResult(msg.params);
|
||||
}
|
||||
});
|
||||
|
||||
function populateCities(enumValues) {
|
||||
cities = enumValues.slice().sort();
|
||||
citySelect.innerHTML = '<option value="">Select a city...</option>';
|
||||
cities.forEach(c => {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = c;
|
||||
opt.textContent = c;
|
||||
citySelect.appendChild(opt);
|
||||
});
|
||||
}
|
||||
|
||||
function handleToolInput(params) {
|
||||
const args = params.arguments || {};
|
||||
if (args.cityState) {
|
||||
citySelect.value = args.cityState;
|
||||
document.getElementById('fetchBtn').disabled = false;
|
||||
document.getElementById('locationInfo').textContent = args.cityState;
|
||||
fetchForecast();
|
||||
}
|
||||
}
|
||||
|
||||
function handleToolResult(params) {
|
||||
const sc = params.structuredContent;
|
||||
if (sc && sc.periods) {
|
||||
if (sc.cityState) {
|
||||
document.getElementById('locationInfo').textContent = sc.cityState;
|
||||
}
|
||||
renderForecast(sc.periods);
|
||||
setStatus(`Forecast loaded — ${sc.periods.length} periods`, 'success');
|
||||
} else if (sc && sc.cityState) {
|
||||
document.getElementById('locationInfo').textContent = sc.cityState;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchForecast() {
|
||||
const city = citySelect.value;
|
||||
if (!city) return;
|
||||
const btn = document.getElementById('fetchBtn');
|
||||
btn.disabled = true;
|
||||
setStatus('Fetching forecast...');
|
||||
document.getElementById('locationInfo').textContent = city;
|
||||
document.getElementById('forecastContainer').innerHTML = '<div class="empty"><div class="spinner"></div><br>Loading forecast...</div>';
|
||||
|
||||
try {
|
||||
const result = await sendRequest('tools/call', {
|
||||
name: 'weather_forecast',
|
||||
arguments: { cityState: city }
|
||||
});
|
||||
const sc = result?.structuredContent || null;
|
||||
if (sc && sc.periods) {
|
||||
renderForecast(sc.periods);
|
||||
setStatus(`Forecast for ${city} — ${sc.periods.length} periods`, 'success');
|
||||
} else if (result?.content?.length > 0) {
|
||||
const text = result.content.map(c => c.text || '').join('\n');
|
||||
document.getElementById('forecastContainer').innerHTML = `<pre style="white-space:pre-wrap;color:var(--text);font-size:11px;">${escapeHtml(text)}</pre>`;
|
||||
setStatus('Forecast loaded (text only)', 'success');
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus('Error: ' + e.message, 'error');
|
||||
document.getElementById('forecastContainer').innerHTML = `<div class="empty"><div class="empty-icon">⚠️</div>${escapeHtml(e.message)}</div>`;
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderForecast(periods) {
|
||||
const container = document.getElementById('forecastContainer');
|
||||
container.innerHTML = '<div class="forecast-grid">' + periods.map(p => {
|
||||
const tempClass = p.temperature >= 90 ? 'hot' : p.temperature >= 70 ? 'warm' : p.temperature >= 50 ? 'cool' : 'cold';
|
||||
const nightClass = p.isDaytime === false ? ' night' : '';
|
||||
const icon = p.isDaytime === false ? '🌙' : getWeatherIcon(p.shortForecast || '');
|
||||
return `<div class="period-card${nightClass}">
|
||||
<div class="period-name">${icon} ${escapeHtml(p.name)}</div>
|
||||
<div class="period-temp ${tempClass}">${p.temperature}°${p.temperatureUnit || 'F'}</div>
|
||||
<div class="period-short">${escapeHtml(p.shortForecast || '')}</div>
|
||||
<div class="period-wind">💨 ${escapeHtml(p.windSpeed || '')} ${escapeHtml(p.windDirection || '')}</div>
|
||||
<div class="period-forecast">${escapeHtml(p.detailedForecast || '')}</div>
|
||||
</div>`;
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
function getWeatherIcon(forecast) {
|
||||
const f = forecast.toLowerCase();
|
||||
if (f.includes('thunder') || f.includes('storm')) return '⛈️';
|
||||
if (f.includes('rain') || f.includes('shower')) return '🌧️';
|
||||
if (f.includes('snow') || f.includes('blizzard')) return '❄️';
|
||||
if (f.includes('cloud') || f.includes('overcast')) return '☁️';
|
||||
if (f.includes('partly') || f.includes('mostly sunny')) return '⛅';
|
||||
if (f.includes('fog') || f.includes('mist')) return '🌫️';
|
||||
if (f.includes('wind')) return '💨';
|
||||
if (f.includes('sunny') || f.includes('clear')) return '☀️';
|
||||
return '🌤️';
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function setStatus(msg, type) {
|
||||
const el = document.getElementById('statusBar');
|
||||
el.textContent = msg;
|
||||
el.className = 'status-bar' + (type ? ` ${type}` : '');
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
try {
|
||||
setStatus('Connecting...');
|
||||
const result = await sendRequest('ui/initialize', { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'weather-forecast', version: '1.0.0' } });
|
||||
sendNotification('ui/notifications/initialized', {});
|
||||
|
||||
// Try to extract enum values from tool inputSchema via hostContext
|
||||
let enumValues = null;
|
||||
const schema = result?.hostContext?.toolInfo?.tool?.inputSchema;
|
||||
if (schema) {
|
||||
enumValues = schema.properties?.cityState?.enum;
|
||||
}
|
||||
|
||||
// Fallback: read city list from a dedicated resource
|
||||
if (!enumValues) {
|
||||
sendNotification('notifications/message', {
|
||||
level: 'warning',
|
||||
data: 'hostContext.toolInfo.tool.inputSchema is missing from ui/initialize response — falling back to resources/read for city list'
|
||||
});
|
||||
try {
|
||||
const res = await sendRequest('resources/read', { uri: 'data://weather-app/us-cities' });
|
||||
const text = res?.contents?.[0]?.text;
|
||||
if (text) {
|
||||
enumValues = JSON.parse(text);
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
if (enumValues && Array.isArray(enumValues)) {
|
||||
populateCities(enumValues);
|
||||
setStatus(`Ready — ${enumValues.length} cities available`);
|
||||
} else {
|
||||
setStatus('Ready (could not load city list)');
|
||||
}
|
||||
} catch (e) { setStatus('Standalone mode: ' + e.message); }
|
||||
}
|
||||
|
||||
initialize();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -50,6 +50,26 @@ internal static class Experimentals
|
|||
/// </summary>
|
||||
public const string Extensions_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp001";
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic ID for experimental MCP Apps extension APIs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// MCP Apps is the first official MCP extension (<c>"io.modelcontextprotocol/ui"</c>), enabling
|
||||
/// servers to deliver interactive UIs inside AI clients. This uses a dedicated diagnostic ID
|
||||
/// so that users can suppress it independently from other experimental APIs.
|
||||
/// </remarks>
|
||||
public const string Apps_DiagnosticId = "MCPEXP003";
|
||||
|
||||
/// <summary>
|
||||
/// Message for the experimental MCP Apps extension APIs.
|
||||
/// </summary>
|
||||
public const string Apps_Message = "The MCP Apps extension is experimental and subject to change as the specification evolves.";
|
||||
|
||||
/// <summary>
|
||||
/// URL for the experimental MCP Apps extension APIs.
|
||||
/// </summary>
|
||||
public const string Apps_Url = "https://github.com/modelcontextprotocol/csharp-sdk/blob/main/docs/list-of-diagnostics.md#mcpexp003";
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic ID for experimental SDK APIs unrelated to the MCP specification,
|
||||
/// such as subclassing <c>McpClient</c>/<c>McpServer</c> or referencing <c>RunSessionHandler</c>.
|
||||
|
|
|
|||
|
|
@ -0,0 +1,44 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net10.0;net9.0;net8.0;netstandard2.0</TargetFrameworks>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ModelContextProtocol.Extensions.Apps</PackageId>
|
||||
<Description>MCP Apps extension for the .NET Model Context Protocol (MCP) SDK</Description>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
<!-- Suppress the experimental MCP warnings for internal usage -->
|
||||
<NoWarn>$(NoWarn);MCPEXP001;MCPEXP003</NoWarn>
|
||||
<!-- New package with no published baseline yet -->
|
||||
<EnablePackageValidation>false</EnablePackageValidation>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(TargetFramework)' != 'netstandard2.0'">
|
||||
<IsAotCompatible>true</IsAotCompatible>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
|
||||
<!-- CS0436: Allow ObsoleteAttribute to be redefined internally -->
|
||||
<NoWarn>$(NoWarn);CS0436</NoWarn>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="..\Common\Experimentals.cs" Link="Experimentals.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Exclude polyfills inherited from Directory.Build.props; this package only needs the ExperimentalAttribute polyfill. -->
|
||||
<ItemGroup>
|
||||
<Compile Remove="..\Common\Polyfills\**\*.cs" />
|
||||
<Compile Include="..\Common\Polyfills\System\Diagnostics\CodeAnalysis\ExperimentalAttribute.cs"
|
||||
Link="Polyfills\ExperimentalAttribute.cs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ModelContextProtocol\ModelContextProtocol.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\PACKAGE.md" Pack="true" PackagePath="\README.md" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace ModelContextProtocol.Extensions.Apps;
|
||||
|
||||
/// <summary>
|
||||
/// Specifies MCP Apps UI metadata for a tool method.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Apply this attribute alongside <see cref="McpServerToolAttribute"/> to associate an MCP Apps
|
||||
/// UI resource with the tool. When processed by <see cref="McpApps.ApplyAppUiAttributes(McpServerTool)"/>
|
||||
/// or <see cref="McpApps.ApplyAppUiAttributes(IEnumerable{McpServerTool})"/>, it populates the
|
||||
/// structured <c>_meta.ui</c> object in the tool's metadata.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Explicit <c>Meta["ui"]</c> set via <see cref="McpServerToolCreateOptions"/> or a raw
|
||||
/// <c>[McpMeta("ui", ...)]</c> attribute takes precedence over this attribute: if the tool
|
||||
/// already has a <c>ui</c> key in <see cref="Tool.Meta"/>, this attribute is ignored.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <code language="csharp">
|
||||
/// [McpServerTool]
|
||||
/// [McpAppUi(ResourceUri = "ui://weather/view.html")]
|
||||
/// [Description("Get current weather for a location")]
|
||||
/// public string GetWeather(string location) => ...;
|
||||
///
|
||||
/// // Restrict visibility to model only:
|
||||
/// [McpServerTool]
|
||||
/// [McpAppUi(ResourceUri = "ui://weather/view.html", Visibility = [McpUiToolVisibility.Model])]
|
||||
/// public string GetWeatherModelOnly(string location) => ...;
|
||||
/// </code>
|
||||
/// </example>
|
||||
[AttributeUsage(AttributeTargets.Method)]
|
||||
[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)]
|
||||
public sealed class McpAppUiAttribute : Attribute
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the URI of the UI resource associated with this tool.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This should be a <c>ui://</c> URI pointing to the HTML resource registered
|
||||
/// with the server (e.g., <c>"ui://weather/view.html"</c>).
|
||||
/// </remarks>
|
||||
public string? ResourceUri { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the visibility of the tool, controlling which principals can invoke it.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Allowed values are <see cref="McpUiToolVisibility.Model"/> and <see cref="McpUiToolVisibility.App"/>.
|
||||
/// When <see langword="null"/> or empty, the tool is visible to both the model and the app (the default).
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public string[]? Visibility { get; set; }
|
||||
}
|
||||
265
src/ModelContextProtocol.Extensions.Apps/Server/McpApps.cs
Normal file
265
src/ModelContextProtocol.Extensions.Apps/Server/McpApps.cs
Normal file
|
|
@ -0,0 +1,265 @@
|
|||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Nodes;
|
||||
|
||||
namespace ModelContextProtocol.Extensions.Apps;
|
||||
|
||||
/// <summary>
|
||||
/// Provides constants and helper methods for building MCP Apps-enabled servers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// MCP Apps is an extension to the Model Context Protocol that enables MCP servers to deliver
|
||||
/// interactive user interfaces — dashboards, forms, visualizations, and more — directly inside
|
||||
/// conversational AI clients.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Use the constants in this class when populating the <c>extensions</c> capability and the
|
||||
/// <c>_meta</c> field of tools and resources. Use <see cref="GetUiCapability"/> to check whether
|
||||
/// the connected client supports the MCP Apps extension.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Use <see cref="SetAppUi"/> to set the <c>_meta.ui</c> metadata on a tool, or
|
||||
/// <see cref="ApplyAppUiAttributes(IEnumerable{McpServerTool})"/> to automatically process
|
||||
/// <see cref="McpAppUiAttribute"/> instances on tools created from methods.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)]
|
||||
public static class McpApps
|
||||
{
|
||||
/// <summary>
|
||||
/// The MIME type used for MCP App HTML resources.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This MIME type should be used when registering UI resources with
|
||||
/// <c>text/html;profile=mcp-app</c> to indicate they are MCP App resources.
|
||||
/// </remarks>
|
||||
public const string HtmlMimeType = "text/html;profile=mcp-app";
|
||||
|
||||
/// <summary>
|
||||
/// The extension identifier used for MCP Apps capability negotiation.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This key is used in the <see cref="ClientCapabilities.Extensions"/> and
|
||||
/// <see cref="ServerCapabilities.Extensions"/> dictionaries to advertise support for
|
||||
/// the MCP Apps extension.
|
||||
/// </remarks>
|
||||
public const string ExtensionId = "io.modelcontextprotocol/ui";
|
||||
|
||||
/// <summary>
|
||||
/// Gets the <see cref="JsonSerializerOptions"/> configured with source-generated metadata
|
||||
/// for MCP Apps extension types.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Use these options when serializing or deserializing MCP Apps types such as
|
||||
/// <see cref="McpUiToolMeta"/>, <see cref="McpUiClientCapabilities"/>, and <see cref="McpUiResourceMeta"/>.
|
||||
/// </remarks>
|
||||
public static JsonSerializerOptions SerializerOptions { get; } = CreateSerializerOptions();
|
||||
|
||||
private static JsonSerializerOptions CreateSerializerOptions()
|
||||
{
|
||||
var options = new JsonSerializerOptions(McpJsonUtilities.DefaultOptions);
|
||||
options.TypeInfoResolverChain.Insert(0, McpAppsJsonContext.Default);
|
||||
options.MakeReadOnly();
|
||||
return options;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the MCP Apps client capability, if advertised by the connected client.
|
||||
/// </summary>
|
||||
/// <param name="capabilities">The client capabilities received during the MCP initialize handshake.</param>
|
||||
/// <returns>
|
||||
/// A <see cref="McpUiClientCapabilities"/> instance if the client advertises support for the MCP Apps extension;
|
||||
/// otherwise, <see langword="null"/>.
|
||||
/// </returns>
|
||||
/// <remarks>
|
||||
/// Use this method to determine whether the connected client supports the MCP Apps extension
|
||||
/// and to read the client's supported MIME types.
|
||||
/// </remarks>
|
||||
public static McpUiClientCapabilities? GetUiCapability(ClientCapabilities? capabilities)
|
||||
{
|
||||
if (capabilities?.Extensions is not { } extensions ||
|
||||
!extensions.TryGetValue(ExtensionId, out var value))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle the case where the value was set programmatically (e.g. in tests
|
||||
// or a non-JSON code path) rather than deserialized from the handshake.
|
||||
if (value is McpUiClientCapabilities uiCapabilities)
|
||||
{
|
||||
return uiCapabilities;
|
||||
}
|
||||
|
||||
if (value is JsonElement element)
|
||||
{
|
||||
// Guard against malformed extension values (e.g. a bare string or number)
|
||||
// that would throw JsonException during deserialization. Return null
|
||||
// consistently for any non-object value, matching the other failure modes.
|
||||
if (element.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (element.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize(element, McpAppsJsonContext.Default.McpUiClientCapabilities);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the MCP Apps UI metadata on a tool's <see cref="Tool.Meta"/> property.
|
||||
/// </summary>
|
||||
/// <param name="tool">The tool to set the UI metadata on.</param>
|
||||
/// <param name="appUi">The UI metadata to apply.</param>
|
||||
/// <returns>The same <paramref name="tool"/> instance, for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method sets the <c>ui</c> key in the tool's <see cref="Tool.Meta"/> object.
|
||||
/// If a <c>ui</c> key is already present in <see cref="Tool.Meta"/>, it is not overwritten.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="tool"/> or <paramref name="appUi"/> is <see langword="null"/>.</exception>
|
||||
public static McpServerTool SetAppUi(McpServerTool tool, McpUiToolMeta appUi)
|
||||
{
|
||||
#if NET
|
||||
ArgumentNullException.ThrowIfNull(tool);
|
||||
ArgumentNullException.ThrowIfNull(appUi);
|
||||
#else
|
||||
if (tool is null) throw new ArgumentNullException(nameof(tool));
|
||||
if (appUi is null) throw new ArgumentNullException(nameof(appUi));
|
||||
#endif
|
||||
|
||||
var protocolTool = tool.ProtocolTool;
|
||||
protocolTool.Meta ??= new JsonObject();
|
||||
|
||||
if (!protocolTool.Meta.ContainsKey("ui"))
|
||||
{
|
||||
var uiNode = JsonSerializer.SerializeToNode(appUi, McpAppsJsonContext.Default.McpUiToolMeta);
|
||||
if (uiNode is not null)
|
||||
{
|
||||
protocolTool.Meta["ui"] = uiNode;
|
||||
}
|
||||
}
|
||||
|
||||
return tool;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the MCP Apps UI metadata on a resource's <see cref="ResourceTemplate.Meta"/> property.
|
||||
/// </summary>
|
||||
/// <param name="resource">The resource to set the UI metadata on.</param>
|
||||
/// <param name="resourceUi">The UI metadata to apply.</param>
|
||||
/// <returns>The same <paramref name="resource"/> instance, for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This method sets the <c>ui</c> key in the resource's <see cref="ResourceTemplate.Meta"/> object.
|
||||
/// If a <c>ui</c> key is already present in <see cref="ResourceTemplate.Meta"/>, it is not overwritten.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="resource"/> or <paramref name="resourceUi"/> is <see langword="null"/>.</exception>
|
||||
public static McpServerResource SetResourceUi(McpServerResource resource, McpUiResourceMeta resourceUi)
|
||||
{
|
||||
#if NET
|
||||
ArgumentNullException.ThrowIfNull(resource);
|
||||
ArgumentNullException.ThrowIfNull(resourceUi);
|
||||
#else
|
||||
if (resource is null) throw new ArgumentNullException(nameof(resource));
|
||||
if (resourceUi is null) throw new ArgumentNullException(nameof(resourceUi));
|
||||
#endif
|
||||
|
||||
var protocolResource = resource.ProtocolResourceTemplate;
|
||||
protocolResource.Meta ??= new JsonObject();
|
||||
|
||||
if (!protocolResource.Meta.ContainsKey("ui"))
|
||||
{
|
||||
var uiNode = JsonSerializer.SerializeToNode(resourceUi, McpAppsJsonContext.Default.McpUiResourceMeta);
|
||||
if (uiNode is not null)
|
||||
{
|
||||
protocolResource.Meta["ui"] = uiNode;
|
||||
}
|
||||
}
|
||||
|
||||
return resource;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes a collection of tools, applying <see cref="McpAppUiAttribute"/> metadata to any
|
||||
/// tool whose underlying method has the attribute.
|
||||
/// </summary>
|
||||
/// <param name="tools">The tools to process.</param>
|
||||
/// <returns>The same <paramref name="tools"/> enumerable, for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// For each tool that has a <see cref="McpAppUiAttribute"/> in its <see cref="McpServerTool.Metadata"/>,
|
||||
/// this method sets the <c>ui</c> key in the tool's <see cref="Tool.Meta"/> if not already present.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// If <see cref="Tool.Meta"/> already contains a <c>ui</c> key (e.g., set explicitly via
|
||||
/// <see cref="McpServerToolCreateOptions.Meta"/>), the attribute is not applied.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="tools"/> is <see langword="null"/>.</exception>
|
||||
public static IEnumerable<McpServerTool> ApplyAppUiAttributes(IEnumerable<McpServerTool> tools)
|
||||
{
|
||||
#if NET
|
||||
ArgumentNullException.ThrowIfNull(tools);
|
||||
#else
|
||||
if (tools is null) throw new ArgumentNullException(nameof(tools));
|
||||
#endif
|
||||
|
||||
foreach (var tool in tools)
|
||||
{
|
||||
ApplyAppUiAttributes(tool);
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes a single tool, applying <see cref="McpAppUiAttribute"/> metadata if the tool's
|
||||
/// underlying method has the attribute.
|
||||
/// </summary>
|
||||
/// <param name="tool">The tool to process.</param>
|
||||
/// <returns>The same <paramref name="tool"/> instance, for chaining.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// If the tool has a <see cref="McpAppUiAttribute"/> in its <see cref="McpServerTool.Metadata"/>,
|
||||
/// this method sets the <c>ui</c> key in the tool's <see cref="Tool.Meta"/> if not already present.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <exception cref="ArgumentNullException"><paramref name="tool"/> is <see langword="null"/>.</exception>
|
||||
public static McpServerTool ApplyAppUiAttributes(McpServerTool tool)
|
||||
{
|
||||
#if NET
|
||||
ArgumentNullException.ThrowIfNull(tool);
|
||||
#else
|
||||
if (tool is null) throw new ArgumentNullException(nameof(tool));
|
||||
#endif
|
||||
|
||||
// Look for McpAppUiAttribute in tool metadata (attributes from the method)
|
||||
foreach (var metadataItem in tool.Metadata)
|
||||
{
|
||||
if (metadataItem is McpAppUiAttribute appUiAttr)
|
||||
{
|
||||
var meta = new McpUiToolMeta
|
||||
{
|
||||
ResourceUri = appUiAttr.ResourceUri,
|
||||
Visibility = appUiAttr.Visibility,
|
||||
};
|
||||
|
||||
SetAppUi(tool, meta);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return tool;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,71 @@
|
|||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ModelContextProtocol.Protocol;
|
||||
using ModelContextProtocol.Server;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
|
||||
namespace ModelContextProtocol.Extensions.Apps;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for <see cref="IMcpServerBuilder"/> to enable MCP Apps support.
|
||||
/// </summary>
|
||||
[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)]
|
||||
public static class McpAppsBuilderExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Enables MCP Apps support by automatically processing <see cref="McpAppUiAttribute"/> on registered tools.
|
||||
/// </summary>
|
||||
/// <param name="builder">The server builder.</param>
|
||||
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// Call this method after registering tools (e.g., after <c>WithTools<T>()</c>) to automatically
|
||||
/// apply <see cref="McpAppUiAttribute"/> metadata to the tool's <c>_meta.ui</c> field.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// Tools that already have a <c>ui</c> key in their <see cref="Protocol.Tool.Meta"/> (e.g., set explicitly
|
||||
/// via <see cref="McpServerToolCreateOptions.Meta"/>) are not modified.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <example>
|
||||
/// <code language="csharp">
|
||||
/// builder.Services
|
||||
/// .AddMcpServer()
|
||||
/// .WithTools<MyToolType>()
|
||||
/// .WithMcpApps();
|
||||
/// </code>
|
||||
/// </example>
|
||||
public static IMcpServerBuilder WithMcpApps(this IMcpServerBuilder builder)
|
||||
{
|
||||
#if NET
|
||||
ArgumentNullException.ThrowIfNull(builder);
|
||||
#else
|
||||
if (builder is null) throw new ArgumentNullException(nameof(builder));
|
||||
#endif
|
||||
|
||||
builder.Services.AddSingleton<IPostConfigureOptions<McpServerOptions>, McpAppsPostConfigureOptions>();
|
||||
return builder;
|
||||
}
|
||||
|
||||
private sealed class McpAppsPostConfigureOptions : IPostConfigureOptions<McpServerOptions>
|
||||
{
|
||||
public void PostConfigure(string? name, McpServerOptions options)
|
||||
{
|
||||
// Advertise server-side MCP Apps support in capabilities.
|
||||
options.Capabilities ??= new ServerCapabilities();
|
||||
options.Capabilities.Extensions ??= new Dictionary<string, object>();
|
||||
if (!options.Capabilities.Extensions.ContainsKey(McpApps.ExtensionId))
|
||||
{
|
||||
options.Capabilities.Extensions[McpApps.ExtensionId] = new System.Text.Json.Nodes.JsonObject();
|
||||
}
|
||||
|
||||
if (options.ToolCollection is { IsEmpty: false } tools)
|
||||
{
|
||||
foreach (var tool in tools)
|
||||
{
|
||||
McpApps.ApplyAppUiAttributes(tool);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.Extensions.Apps;
|
||||
|
||||
/// <summary>
|
||||
/// Provides source-generated JSON serialization metadata for MCP Apps extension types.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
|
||||
[JsonSerializable(typeof(McpUiToolMeta))]
|
||||
[JsonSerializable(typeof(McpUiClientCapabilities))]
|
||||
[JsonSerializable(typeof(McpUiResourceMeta))]
|
||||
[JsonSerializable(typeof(McpUiResourceCsp))]
|
||||
[JsonSerializable(typeof(McpUiResourcePermissions))]
|
||||
internal sealed partial class McpAppsJsonContext : JsonSerializerContext
|
||||
{
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace ModelContextProtocol.Extensions.Apps;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the MCP Apps capabilities advertised by a client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <para>
|
||||
/// This object is the value associated with the <c>"io.modelcontextprotocol/ui"</c> key in the
|
||||
/// <see cref="Protocol.ClientCapabilities.Extensions"/> dictionary.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
[Experimental(Experimentals.Apps_DiagnosticId, UrlFormat = Experimentals.Apps_Url)]
|
||||
public sealed class McpUiClientCapabilities
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the list of MIME types supported by the client for MCP App UI resources.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A client that supports MCP Apps must include <c>"text/html;profile=mcp-app"</c> in this list.
|
||||
/// </remarks>
|
||||
[JsonPropertyName("mimeTypes")]
|
||||
public IList<string>? MimeTypes { get; set; }
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue