| name | shiny-httpserver |
| description | Generate code using Shiny.Net.HttpServer — a dependency-light, AOT/trim-clean HTTP/1.1, HTTP/2 & HTTP/3 server that runs anywhere .NET runs, including .NET MAUI and native tvOS, where ASP.NET Core cannot. Covers routing, middleware, source-generated typed endpoints, results and JSON, content negotiation with XML/MessagePack/protobuf formatters in both directions, static files and Blazor WASM, uploads/downloads, WebSockets, SSE, sessions, OpenAPI, authentication (Basic/API key/cookie/JWT), authorization, CORS, rate limiting, IP filtering, TLS and self-signed certificates, tunnelling (relay, SSH, quick tunnels, Azure Relay, and supervised cloudflared/ngrok/tailscale agents), serving a directory over WebDAV, serving gRPC and gRPC-Web, hosting an MCP server with RFC 9728 OAuth discovery, health checks, OpenTelemetry-shaped metrics and tracing, W3C access logs, request timeouts, output caching and conditional requests, request decompression, antiforgery and browser security headers, a reverse proxy with destination clusters, load balancing, health checks, session affinity, transforms, WebSocket forwarding and IConfiguration-driven routes, mDNS/Bonjour advertising and discovery, MAUI lifecycle (background/foreground, Android foreground service, network rebinding), and an in-memory test harness. |
| auto_invoke | true |
| triggers | ["Shiny.Net.HttpServer","HttpServer","embedded http server","http server in MAUI","http server on tvOS","tvos","Apple TV","web server on device","HttpServerOptions","ShinyHttpServerBuilder","AddShinyHttpServer","AddHttpServer","MapGet","MapPost","OnRequest","IHttpMiddleware","IResponseBodyControl","request logging","traffic recorder","capture request body","capture response body","read body twice","RequestDelegate","HttpContext","RouteAttribute","IHttpEndpoint","IEndpointModule","MapMyAppEndpoints","FromRoute","FromQuery","FromBody","FromServices","[Truncated]"] |
Shiny HTTP Server Skill
Triggers
- Shiny.Net.HttpServer
- embedded / in-app HTTP server
- HTTP server in a .NET MAUI app
- serving a web UI or API from a device
- tunnelling a device server to the internet
- hosting an MCP server without ASP.NET Core
You are an expert in Shiny.Net.HttpServer, a dependency-light HTTP server for .NET.
When to Use This Skill
Invoke this skill when the user wants to:
- Serve HTTP from an app that cannot use ASP.NET Core (.NET MAUI, single-file, embedded, AOT)
- Add routes, middleware or typed endpoints to a
Shiny.Net.HttpServer app
- Serve static files, a Blazor WebAssembly app, or a device's file system over HTTP
- Expose a folder as a WebDAV mount so it appears as a drive in Finder or Windows Explorer
- Read or write bodies in XML, MessagePack, protobuf or a format of their own instead of JSON
- Add authentication, authorization, CORS, rate limiting or IP filtering to that server
- Make a device-local server reachable from the internet through a tunnel
- Serve gRPC or gRPC-Web from an app that cannot run ASP.NET Core
- Host a Model Context Protocol server inside a non-ASP.NET app, including one a remote client must
authenticate to
- Find or be found by another device on the same network without anyone typing an IP address
- Keep an embedded server working as a phone is backgrounded, resumed, or moved between networks
- Report health, metrics or traces from an embedded server
- Cache responses, honour conditional requests, bound how long a handler may take, or forward a
route to another server
- Test endpoints without binding a port
Do not use this skill for ASP.NET Core / Kestrel / minimal APIs. Those are a different library
with similar-looking names.
Library Overview
Documentation: https://shinylib.net/httpserver
Only Microsoft.Extensions.* abstractions are taken as dependencies. Everything else — JSON, crypto,
JWT, OpenAPI, HPACK, QPACK — is in the box. Everything targets net10.0 with the trim, AOT and
single-file analyzers on.
The single hard rule: nothing is discovered by reflection. Routes and binders are generated at
compile time; JSON goes through JsonTypeInfo from a JsonSerializerContext. Any code you generate
must hold that line, or it fails on a trimmed device build.
Packages
dotnet add package Shiny.Net.HttpServer
dotnet add package Shiny.Net.HttpServer.Jwt
dotnet add package Shiny.Net.HttpServer.Proxy
dotnet add package Shiny.Net.HttpServer.Ssh
dotnet add package Shiny.Net.HttpServer.AzureRelay
dotnet add package Shiny.Net.HttpServer.Mcp
dotnet add package Shiny.Net.HttpServer.Mediator
dotnet add package Shiny.Net.HttpServer.DocumentDb
dotnet add package Shiny.Net.HttpServer.WebDav
dotnet add package Shiny.Net.HttpServer.Grpc
dotnet add package Shiny.Net.HttpServer.Discovery
dotnet add package Shiny.Net.HttpServer.Mobile
dotnet add package Shiny.Net.HttpServer.Testing
dotnet add package Shiny.Net.HttpServer.Tunnels
dotnet tool install -g Shiny.Net.HttpServer.CommandLine
Shiny.Net.HttpServer.CommandLine is a .NET tool, not something an app references: it serves a
directory over HTTP from a terminal (shinyhttpserver [path] -m read|create|update|delete|all -u user:password). Reach for it when the ask is "serve this folder", not "add a server to my app".
It mounts the directory over WebDAV, so one address is both a browser file manager (browse,
upload, rename, delete — whatever -m allows) and a drive Finder, Explorer or a Linux file manager
can mount. Scripting it is WebDAV: GET/PUT/DELETE as usual, MKCOL for a directory, MOVE
for a rename, and PROPFIND with Depth: 1 for a machine-readable listing — a GET on a directory
returns the manager's HTML, not JSON. -m is enforced before the handler runs, across MKCOL,
COPY and MOVE as well as PUT; a MOVE is judged by where it lands, and renaming needs
update and delete.
It listens on every interface by default and ends its banner with a scannable QR code of the LAN
address plus the URL in full, so "get this folder onto my phone" is the tool answer, not code —
-a localhost keeps it to the machine, --no-qr drops the code. Basic auth (-u) over plain HTTP
refuses to start on a non-loopback address, which the default now is: pair it with --https.
--tunnel is the answer to "share this folder with someone not on my network": it opens a
QuickTunnel to pinggy.io and the QR code carries the public HTTPS address instead of the LAN one.
Because the tunnel feeds HttpServer.ServeAsync directly, --tunnel -a localhost binds nothing on
the LAN and is reachable only through the tunnel — and a tunnelled connection counts as encrypted
transport, so -u works over it without --https or --allow-insecure-auth. Anonymous tunnels stop
after 60 minutes; --tunnel-token <token> lifts that and implies --tunnel. Always say that the
address is public when you suggest it.
The four tiers — the spine of this library
Every new API belongs to one of these. Say which when you introduce one. They compose in one app.
| Tier | What it is | Use when |
|---|
| 0 | OnRequest(ctx => …) — one delegate, no routing | A single handler, a test fixture, a fallback |
| 1 | MapGet/MapPost/… — raw handlers behind a route template | A handful of routes, no binding wanted |
| 2 | Use(...) / IHttpMiddleware — the pipeline | Cross-cutting work |
| 3 | [Route] classes + the source generator | Anything real: typed parameters, DI, OpenAPI |
Default to tier 3 when the user has more than a couple of endpoints or wants typed parameters.
Default to tier 1 for small, script-like servers. Never suggest reflection-based alternatives.
Setup
Choose the host shape from who owns the container:
var server = new HttpServer(new HttpServerOptions { Port = 8080 });
server.MapGet("/ping", ctx => ctx.Response.WriteAsync("pong"));
await server.RunAsync();
var builder = HttpServer.CreateBuilder();
builder.Options.Port = 8080;
builder.AddAuthentication().AddJwtBearer(o => o.SigningKey = key);
builder.Services.AddSingleton<IWidgetStore, WidgetStore>();
var app = builder.Build();
app.MapMyAppEndpoints();
await app.RunAsync();
services.AddShinyHttpServer(
http =>
{
http.Options.Address = IPAddress.Any;
http.Options.Port = 0;
http.AddAuthentication().AddBasic<CredentialStore>(o => o.Realm = "Device");
http.Configure(server => server.MapMyAppEndpoints());
},
autoStart: false
);
Every registration in this library is an extension on ShinyHttpServerBuilder, not on
IServiceCollection — AddAuthentication, AddCors, AddRateLimiter, AddHealthChecks,
AddOutputCache, AddSessions, the tunnels, discovery, the mobile lifecycle. Generate
builder.AddX(...), never builder.Services.AddX(...), for anything this library owns;
builder.Services is for the app's own registrations. The one exception is
services.AddHttpServerLocator(), which is the client half of mDNS and has no server to hang off.
http.Configure(server => ...) is where routes and middleware go in shape (c) — it runs when the
server is first resolved, so it can pull things out of the container.
autoStart: false + server.StartAsync() from the UI is the right shape for MAUI. Port = 0 lets
the OS pick; read it back from server.ListenUrl.
Defaults worth knowing
- Binds loopback by default. Set
Address = IPAddress.Any for LAN access — deliberately.
Limits.MaxRequestBodySize is 30 MB; raise it for uploads.
HideExceptionDetails is on; turn it off in development only.
Lifecycle — and knowing why the server stopped
Start and stop are runtime operations, not just process startup and shutdown. An app with a toggle
flips this switch repeatedly, so the transitions are serialized, idempotent and restartable.
await server.StartAsync();
await server.StopAsync();
await server.RestartAsync();
StateChanged gives a UI its transitions. StateTransitioned is what to generate whenever the app
needs to diagnose a server that went down — it carries an HttpServerStateChange with the reason
and the exception:
server.StateTransitioned += (_, change) =>
{
if (change is { State: HttpServerState.Stopped, Reason: not HttpServerStateReason.Requested and not HttpServerStateReason.Restarting })
logger.LogError(change.Exception, "The server went down: {Reason}", change.Reason);
};
HttpServerStateReason | Meaning |
|---|
Requested | The app called StartAsync/StopAsync. The only reason that is never a fault |
Restarting | Part of a RestartAsync — including the stop half, so a subscriber knows a start is coming |
NetworkChanged | A rebind driven by the addresses changing |
BindFailed | The bind was refused and the retries are spent; Exception says why |
ListenerFaulted | The listener stopped accepting while the server believed it was running |
Disposed | The server was disposed |
server.LastStateChange is the same record, for code that was not subscribed at the time — a crash
report, a diagnostics screen, a background task that woke up to find the server down.
Resilience is on by default; do not generate an opt-in for it. A transient accept failure is
retried with backoff; a listener that dies underneath a running server is logged at error, reported
as ListenerFaulted, and rebound; the start half of a restart or a network rebind retries
(StartRetryAttempts, default 5). A plain StartAsync is deliberately not retried — its caller
gets the exception. Tune with RecoverFromListenerFaults, StartRetry* and AcceptRetry* only when
asked.
Handlers on StateChanged, StateTransitioned and NetworkAddressesChanged are isolated: one that
throws is logged and the rest still run. Do not wrap them in defensive try/catch of your own.
Tier 3: typed endpoints (preferred)
[Route("/api/widgets")]
public class WidgetEndpoints(IWidgetStore store, ILogger<WidgetEndpoints> logger)
{
[Get("/{id:int}")]
[Produces(200, typeof(Widget))]
[Produces(404)]
public async Task<IActionResult> GetWidget(int id, CancellationToken ct)
=> await store.FindAsync(id, ct) is { } w ? new OkObjectResult(w) : new NotFoundResult();
[Get]
public async Task<IReadOnlyList<Widget>> List(int take = 10, string? search = null,
CancellationToken ct = default) => await store.ListAsync(take, search, ct);
[Post]
public async Task<IActionResult> Create(CreateWidget request, CancellationToken ct)
=> new CreatedResult($"/api/widgets/{(await store.AddAsync(request.Name, ct)).Id}");
}
app.MapWidgetEndpoints();
app.MapMyAppEndpoints();
Rules to follow when generating endpoint classes:
- Use primary constructors for dependencies — they are resolved from the request scope.
- Class must be
public or internal, non-static, non-abstract, non-generic (SWS004).
- Verb attributes:
[Get], [Post], [Put], [Delete], [Patch], or [HttpMethod("VERB", "/t")].
[NonEndpoint] excludes a public method.
- Always accept and pass
CancellationToken.
Binding conventions (do not add attributes when the convention already fits)
In order: ambient types → route token → query → JSON body → container.
HttpContext, HttpRequest, HttpResponse, CancellationToken are handed over directly.
- A parameter whose name matches a route token and whose type is
IParsable binds from the route.
- Anything else
IParsable (plus enums, nullables, and arrays of those) binds from the query.
- A complex type on a body-carrying verb binds from JSON — at most one per method (SWS007).
- Everything else comes from the container.
Overrides: [FromRoute], [FromQuery], [FromHeader], [FromBody], [FromServices], each with an
optional Name.
A default value makes a parameter optional. Bind failures are 400s naming the parameter and type,
raised before the method is called.
Return types
| Return | Response |
|---|
void / Task / ValueTask | Nothing — you wrote the response yourself |
IResult / IActionResult | Executed |
string | text/plain |
| Anything else | JSON from compile-time metadata |
All may be wrapped in Task<T>/ValueTask<T>. Anything else is SWS003.
One endpoint per class
[Get("/health/{component}")]
public class HealthEndpoint(IHealthChecks checks) : IHttpEndpoint
{
public async Task<IActionResult> HandleAsync(string component, CancellationToken ct)
=> await checks.RunAsync(component, ct) ? new OkResult() : new StatusCodeResult(503);
}
Exactly one public Handle/HandleAsync (SWS010) and a verb attribute on the class (SWS011).
Runtime-mounted route groups
public sealed class AdminModule : IEndpointModule
{
public void Map(IEndpointRouteBuilder endpoints)
=> endpoints.MapPost("/admin/reset", ctx => …).RequireAuthorization("admin");
}
app.MapModule(new AdminModule());
app.UnmapModule<AdminModule>();
JSON — the AOT rule
Always declare a JsonSerializerContext covering every type that crosses an endpoint boundary:
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(Widget))]
[JsonSerializable(typeof(IReadOnlyList<Widget>))]
[JsonSerializable(typeof(CreateWidget))]
public partial class AppJson : JsonSerializerContext;
- With the source generator referenced, registration is emitted for you (a module initializer),
and a missing type is build warning SWS006.
- Without it, register by hand:
JsonTypeInfoRegistry.Register(AppJson.Default);
- Never generate
Results.Json(value, options) (the reflection overload) — it is
[RequiresUnreferencedCode]/[RequiresDynamicCode].
- Do not try to emit the context from a generator: generators cannot see each other's output. The app
owns it.
Reading a body from a raw handler: await ctx.Request.ReadJsonAsync(AppJson.Default.NewNote)
(returns null for absent/malformed — return a 400, do not throw).
Results
Results.X() and the MVC-shaped types are the same objects: Results.NotFound() ≡
new NotFoundResult(). Mix freely; prefer IActionResult types inside endpoint classes and
Results.* in raw handlers.
Common: Ok(), Ok(value), Created(location, value), NoContent(), BadRequest(message),
Unauthorized(), Forbidden(), NotFound(), Conflict(), StatusCode(n), Text, Bytes,
Stream, File, Redirect, Json, Negotiate, Problem, ValidationProblem,
ServerSentEvents.
Formats other than JSON
JSON is the default and is what to generate unless the user asks for something else. When they do,
do not reach for XmlSerializer, DataContractSerializer, or MessagePack-CSharp's default
resolver — all of them build their mapping by reflecting over the type and break a trimmed or AOT
build. Register a formatter instead. Formats plug into content negotiation, which lives at tier 1
(configuration) and applies to all four tiers above it:
builder.AddContentNegotiation(o =>
{
o.NegotiateByDefault = true;