Skip to content
Shiny Controls v1.0 - The Ultra Control Suite for .NET MAUI & BlazorO...M...G!

Model Context Protocol

Shiny.Net.HttpServer.Mcp puts a Model Context Protocol server on a route of the Shiny HTTP server, speaking the Streamable HTTP transport. It is the piece the MCP SDK’s own ASP.NET Core package would otherwise supply — which is why this exists, since ASP.NET Core does not run in a .NET MAUI app.

NuGet package Shiny.Net.HttpServer.Mcp
Frameworks
.NET
.NET MAUI
Operating Systems
Android
iOS
macOS
Windows
Linux

The MCP server itself — its tools, prompts and resources — is configured with the MCP SDK’s own AddMcpServer(). What this package adds is the HTTP in front of it.

var builder = HttpServer.CreateBuilder();
builder.Configure(o => o.Port = 8181);
builder.Services
.AddMcpServer(o =>
{
o.ServerInfo = new Implementation { Name = "thermostat", Version = "1.0.0" };
o.ServerInstructions = "Reads and adjusts a thermostat. Temperatures are in Celsius.";
})
.WithTools<ThermostatTools>()
.WithHttpTransport();
var app = builder.Build();
app.MapMcp(); // http://host:port/mcp
await app.RunAsync();

MapMcp() mounts one path across four verbs: POST for client messages, GET for the server-to-client stream, DELETE to end a session, and OPTIONS for the browser preflight. It returns a handle covering all four, so a convention applies to the set rather than to whichever route happened to be mapped last:

app.MapMcp("/tools").RequireAuthorization();

Authorization is applied to the three protocol verbs and deliberately not to the preflight — a browser sends OPTIONS without credentials, so rejecting it would make the endpoint unreachable rather than more secure.

Tools are ordinary methods, and constructor and parameter injection work exactly as they do in an endpoint class, because the MCP server is created from the HTTP server’s own container:

[McpServerToolType]
public sealed class ThermostatTools
{
[McpServerTool(Name = "get_temperature"), Description("Reads the current temperature in Celsius.")]
public static string GetTemperature(Thermostat thermostat)
=> $"Currently {thermostat.Current:0.0}°C, set to {thermostat.Target:0.0}°C.";
}

An MCP client that meets a bare 401 has no idea where to authenticate. RFC 9728 is the protocol’s answer: the challenge names a metadata document, and the document names the authorization servers and the scopes.

builder.AddMcpProtectedResource(o =>
{
o.AuthorizationServers.Add("https://login.example.com");
o.ScopesSupported.Add("mcp:tools");
o.ResourceName = "Kitchen thermostat";
});
app.MapMcp().RequireAuthorization();
app.MapMcpProtectedResource();

Two paths are mounted — /.well-known/oauth-protected-resource and the path-suffixed /.well-known/oauth-protected-resource/mcp that RFC 9728 §3.1 defines for a resource that is not at the root — because a client may ask for either. Both are anonymous and served with Access-Control-Allow-Origin: *: the document is public by definition, and a browser-based client fetches it cross-origin before it holds any credential at all.

A denied request is then answered:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://device.example.com/.well-known/oauth-protected-resource/mcp"

Validating the tokens themselves is still the JWT package’s job, and the audience it validates should be the same Resource published here.

Leave Resource null for a tunnelled server: it is then derived per request from the host, which is the only thing that can be right when the public address is decided at runtime and is new every run. Set it when the server sits behind a fixed name, because the identifier ends up inside issued tokens and has to match what the authorization server was told.

A 403 is left alone — the caller is known and still not allowed, and pointing them at the login they already completed would send them round a loop.

The package is trim- and AOT-clean, and publishes clean under PublishAot. There is one thing the compiler cannot check for you.

A tool’s parameter and return types are published to the client as a JSON schema, and building that schema by reflection does not survive trimming — which covers .NET MAUI on iOS and Mac Catalyst, and anything published with PublishTrimmed or PublishAot. Tools whose parameters and results are only primitives and strings need nothing extra. Anything richer needs a source-generated context:

[JsonSerializable(typeof(Query))]
[JsonSerializable(typeof(Reading))]
[JsonSerializable(typeof(IReadOnlyList<Reading>))]
public partial class ToolJson : JsonSerializerContext;
builder.Services
.AddMcpServer()
.WithTools<SensorTools>(ToolJson.Default.Options)
.WithHttpTransport();

Miss one and MapMcp() throws at startup, naming the type it could not describe and showing the context to add. That is the whole reason the check is worth having: without it the app compiles clean, publishes clean, and fails the first time a client asks for the tool list — on the device.

A tool’s types are often ones the app already serializes. In the MAUI sample the HTTP API and the MCP tools trade in the same DeviceSummary and Note, so the context the app already declares is simply handed to WithTools:

builder.Services
.AddMcpServer(o => o.ServerInfo = new Implementation { Name = "shiny-device", Version = "1.0.0" })
.WithTools<DeviceTools>(ApiJsonContext.Default.Options)
.WithHttpTransport();

By default a client that initializes gets a session, and it lives across requests and across connections — one process on a device holding real state between calls, which is the interesting case for this server. Sessions are also what the GET stream attaches to, so anything needing the server to speak first (sampling, elicitation, roots, notifications) needs one.

.WithHttpTransport(o =>
{
o.IdleSessionTimeout = TimeSpan.FromMinutes(10);
o.MaxSessions = 32;
})

A client that goes away without sending DELETE — which is most of them, most of the time — is reclaimed by IdleSessionTimeout. MaxSessions is a ceiling: exceeding it answers 429 rather than accepting work the device cannot hold. A session with a request still open is never idle, so an SSE stream sitting quietly for an hour is not reclaimed out from under its client.

Set Stateless = true to run every request against a throwaway server with no session state, which is what you want behind a load balancer where the next request may not reach the process that answered this one.

AllowedOrigins is empty by default, and that is the safe setting: a request carrying Origin is by definition coming from a page, and a server bound to localhost is otherwise a DNS-rebinding target. Native MCP clients send no Origin and are unaffected.

.WithHttpTransport(o => o.AllowedOrigins.Add("http://localhost:6274")) // the MCP Inspector

AllowAnyOrigin is convenient while developing and is exactly the setting that makes a locally bound MCP server reachable from any page the user happens to have open.