Add MCP Apps extension support (typed metadata, attribute, and helpers) (#1484)

This commit is contained in:
Copilot 2026-06-17 13:28:47 -07:00 • committed by GitHub
commit f93df7e172
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1896 additions and 1 deletions

View 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();

View 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.
![UI of the Weather Forecast MCP App in VS Code](./WeatherUI.png)
## 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 |

View 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))
};
}

View 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>

View 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);
}
}

View 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
})
};
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

View 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>