Shiny.Net.HttpServer — A Real HTTP Server Where ASP.NET Core Will Not Go


I wanted to point an AI client at an app running on my phone.

Not at a cloud service that the phone syncs with. At the phone. The device is where the interesting data already lives — the notes, the files, the sensors, the thing the user actually did five minutes ago — and I wanted an MCP server sitting right there, on the device, so a model could ask it questions directly.

The MCP SDK has an HTTP transport. Lovely. It is an ASP.NET Core package.

And that is the wall. ASP.NET Core does not run on .NET MAUI. It never has. It is not a MAUI problem or an iOS problem exactly — it is that Kestrel and the hosting stack assume a world of Microsoft.AspNetCore.App, a framework reference you cannot take on a phone. So the moment you want to serve HTTP from a device rather than call it, you are on your own.

I have hit this wall enough times now — an MCP endpoint, a webhook receiver for local debugging, a “send this file to my laptop” screen, a config UI for a headless appliance, a Blazor WASM app served off a Raspberry Pi — that I finally stopped working around it.

So: Shiny.Net.HttpServer. A real HTTP server, written from scratch, that runs anywhere .NET runs. It is in beta today.

var server = new HttpServer(new HttpServerOptions { Port = 8080 });
server.OnGet("/ping", ctx => ctx.Response.WriteAsync("pong"));
await server.RunAsync();

That is the whole thing running. Three lines, no host builder, no Microsoft.AspNetCore.App.

What “dependency-light” actually means here

This is the part I care about most, so let me be precise rather than hand-wavy.

The only dependencies are Microsoft.Extensions.* abstractions. That is it. Everything else is built on what ships in the box:

  • JSON is System.Text.Json, source-generated, no reflection
  • The JWT package does its own crypto — no Microsoft.IdentityModel, no System.IdentityModel.Tokens.Jwt
  • HTTP/2’s HPACK and HTTP/3’s QPACK are implemented in the library
  • The OpenAPI document is assembled from compile-time metadata, not a document object model
  • TLS certificates — including self-signed ones generated on the device — are managed code

I did not do this to be clever. I did it because every one of those is a package that either drags in a dependency graph you cannot afford on a phone, or does something reflective that dies under trimming. And on iOS, trimming is not a choice you get to make.

Which leads to the rule the whole repo is built around: every shipping project has the trim, AOT and single-file analyzers turned on. “AOT-clean” is a build failure if you get it wrong, not a sentence in a readme. There is exactly one package that opts out, and it says so loudly and explains why — more on that below.

The four tiers

The thing I wanted to avoid was a framework with one correct way to write an endpoint. So there are four, each built on the one below, and they compose in the same app.

Tier 0 — one delegate. No routing, no ceremony. You get a request, you write a response.

server.OnRequest(async ctx => await ctx.Response.WriteAsync("hello"));

Tier 1 — routes. Templates with constraints, and they are mutable at runtime, which matters more than it sounds when the server is embedded in an app that toggles features.

server.OnGet("/api/users/{id:int}", async ctx => { /* … */ });

Tier 2 — middleware. ASP.NET-shaped, because everyone already knows the shape.

server.UseAuthentication();
server.Use(new MyRequestCounter());
server.UseEmbeddedFiles(typeof(App).Assembly, "MyApp.wwwroot");

Tier 3 — source-generated typed endpoints. A class, constructor injection, typed parameters bound at compile time. The generator emits the route registration, the binding and the OpenAPI metadata. No reflection at runtime, nothing to trim away.

[Route("/api/users")]
public class UserEndpoints(IUserService users, ILogger<UserEndpoints> logger)
{
    [Get("/{id:int}")]
    public async Task<IActionResult> GetUser(int id, CancellationToken ct)
        => await users.FindAsync(id, ct) is { } u ? new OkObjectResult(u) : new NotFoundResult();
}

app.MapMyAppEndpoints();   // emitted for every [Route] class in the assembly

Both result spellings work — Results.Ok(…) if you think in minimal APIs, IActionResult if you think in MVC. I got tired of picking sides.

What is in the box

The short version, because the docs go deeper on all of it:

  • Protocols — HTTP/1.1, HTTP/2, HTTP/3, WebSockets, Server-Sent Events. The version is never guessed: ALPN over TLS, connection preface over cleartext.
  • Content — static files from disk or embedded resources, a published Blazor WebAssembly app, streaming multipart uploads, byte ranges and conditional GETs, a file browser over a directory, brotli/gzip/deflate.
  • Security — authentication and authorization split ASP.NET-style, with Basic, API key, cookie and JWT schemes, plus policies, roles, claims, CORS, rate limiting and IP filtering — all with per-endpoint policies.
  • TLS — multiple endpoints with per-endpoint TLS, self-signed certificates generated in managed code (iOS and Android included), client certificates, and SPKI pinning for your own HttpClient.
  • Lifecycle — start, stop and restart at runtime, serialized and idempotent, with observable state. An embedded server gets toggled, not just booted, and that turned out to be a surprising amount of the design.

A real IServiceScope per request, RFC 9457 problem details, and an exception-handler chain are all there too. Route constraints cover the types you would expect and a few you have to hand-roll elsewhere — {id:byte}, {on:dateonly}, {page:range(1,100)} — and a constraint decides whether a route matches, never converting anything, so a refused segment is a 404 and one that matched but will not parse is a 400. Responses can send trailing headers on all three protocol versions, which is the bit I needed for gRPC and had not appreciated was missing.

Getting a phone onto the internet

A server on a phone is useless if nothing can reach it. A cellular device sits behind carrier-grade NAT with no routable address and no port you could forward, and you cannot ship an agent process like ngrok or cloudflared to iOS — there is no process to spawn.

So tunnelling is a first-class part of this, through a pluggable ITunnelProvider. The device opens an ordinary outbound connection and asks the far end to forward traffic back down it. There is a reference relay (both ends), an Azure Relay provider, and SSH remote forwarding — ssh -R, in library form, in pure managed code so it runs on iOS.

And then the one I actually use, which needs no account and nothing installed:

builder.Services.AddQuickTunnel();

// from a Share button:
var url = await tunnel.StartAsync();   // https://xxxxx.free.pinggy.net

QuickTunnel is INotifyPropertyChanged, so a view binds straight to PublicUrl and State. Bind to it — do not read it once. A free tunnel hands out a different address on every reconnect, and a phone reconnects every time it changes network. An app that painted the first URL on a label is showing a dead link ten minutes later.

A war story from building this, since it is the kind of thing that only shows up against real servers: the default host used to be localhost.run, and it never worked. It forwards fine, but it never confirms the SSH session request that carries your assigned URL. The ssh binary does not wait for that confirmation and prints the address anyway; SSH.NET waits, and there is no supported way around it because the channel types are internal to the library. So the tunnel came up, stalled for thirty seconds, and then reported a URL scraped out of the provider’s welcome banner — which was a link to their dashboard. It looked like it worked. It absolutely did not.

That is fixed. The default is a host that answers, the URL pattern is anchored per provider instead of grabbing the first https:// it sees, the capture is properly time-bounded, and a tunnel that cannot learn its address now returns null and tells you why instead of inventing one. Which is a better failure than the one I shipped.

An MCP server in your pocket

Back to where this started.

builder.Services
    .AddMcpServer(o => o.ServerInfo = new() { Name = "thermostat", Version = "1.0.0" })
    .WithTools<ThermostatTools>()
    .WithHttpTransport();

var app = builder.Build();
app.MapMcp();              // POST/GET/DELETE/OPTIONS on /mcp

That is a Model Context Protocol server, Streamable HTTP, hosted inside a MAUI app. Put a quick tunnel in front of it and you can paste the URL into an MCP client and have a model talking to the device from anywhere.

The MCP package is trim- and AOT-clean like the rest, with one wrinkle the compiler cannot catch for you. A tool’s parameter and return types get published to the client as a JSON schema, and building that schema by reflection does not survive trimming. Tools that trade only in primitives need nothing. For everything else, hand it a source-generated context:

[JsonSerializable(typeof(Query))]
[JsonSerializable(typeof(IReadOnlyList<Reading>))]
public partial class ToolJson : JsonSerializerContext;

.WithTools<ThermostatTools>(ToolJson.Default.Options)

Miss one and MapMcp() throws at startup naming the type and the context to add — rather than handing you a NotSupportedException from deep inside the container on the first request, which is what it used to do.

Then it kept growing

I meant to stop at the MCP transport. What actually happened is that once a real server existed, every “I wish the device could just serve this” idea I had shelved over the years became a half-day’s work. Four more packages came out of that, plus the thing I should have built first.

Speaking something other than JSON

JSON covers most of what an HTTP API does, and “most” is doing a lot of work in that sentence. The integration on the far side of a corporate gateway wants XML. The battery-powered client would rather not spend 40% more radio time on braces. The service upstream already has protobuf messages generated from a .proto.

So formats plug in on both sides now — IOutputFormatter was always there for responses, and IInputFormatter is the half that was missing:

builder.Services.AddContentNegotiation(o =>
{
    o.NegotiateByDefault = true;
    o.AddXml();
    o.AddMessagePack();
});

That is the whole setup, and no endpoint changes. Request bodies are dispatched on Content-Type, so every [FromBody] parameter you already wrote now accepts XML and MessagePack; responses come from Accept.

Here is the part I find genuinely interesting. The obvious way to do XML is XmlSerializer, and it is unusable here — it builds its mapping by reflecting over your type at runtime, which is exactly what a trimmed app has thrown away. Same story for MessagePack-CSharp’s default resolver. The way out was to stop looking for a second serializer at all: your types already have source-generated JsonTypeInfo metadata, with the property names and converters already decided, so XML and MessagePack are written against that. Which means no dependency, no [XmlRoot], no [MessagePackObject], no second set of attributes to keep in sync with the first — and the JSON and XML representations of an endpoint literally cannot drift apart, because there is only one source for both.

Reading XML has to be type-directed for the same reason, and this is the bug I most wanted to avoid shipping: XML has no types. <postalCode>01234</postalCode> is text. Guess from the text and that postal code arrives as the number 1234, in production, quietly. Ask the target member what it is and it stays a string.

Protobuf is the honest exception. It cannot be produced without a schema — field numbers live in the .proto, and the only thing that has them is the code protoc already generated — so you hand over the pair your generated messages already expose, one line each:

o.AddProtobuf(p => p.Add<Reading>(m => m.ToByteArray(), Reading.Parser.ParseFrom));

No new dependency in the package, and nothing reflecting over your messages. Same registry takes CBOR, Avro, or MessagePack-CSharp’s native codec if you want the real thing rather than my transcoder.

One behaviour change worth flagging: a body whose Content-Type nothing reads is now a 415, where it used to be a 400. “Your JSON is broken” sends someone hunting for a syntax error that is not there. “I do not speak protobuf” sends them to fix a header.

gRPC, on a phone

This one still amuses me. Grpc.Net.Client, grpcurl, any gRPC client in any language — talking to a service hosted inside a MAUI app.

app.MapGrpcService("greet.Greeter", svc =>
{
    svc.AddMarshaller<HelloRequest>(m => m.ToByteArray(), HelloRequest.Parser.ParseFrom);
    svc.MapUnary<HelloRequest, HelloReply>("SayHello", (req, ctx) => …);
});

All four method shapes, streams as IAsyncEnumerable<T> in both directions flushed as they yield, deadlines from grpc-timeout arriving as your CancellationToken, per-message compression, status in trailers. Marshalling is yours for the same reason as protobuf above, which is also why the package adds no dependency.

gRPC-Web is on by default in both framings, and that is not a nicety — it is how a browser calls in, and how anything stuck on HTTP/1.1 does. It matters here more than it does on a server: native gRPC needs a tunnel that forwards raw TCP, and most of the hosted ones terminate HTTP/1.1, so gRPC-Web is what actually reaches a phone from the internet.

MaxReceiveMessageSize is enforced on the decompressed size, not just the length prefix. A few compressed kilobytes expanding into gigabytes is not a message size problem, it is a denial of service.

Your app’s storage, as a drive in Finder

The file browser I shipped is a JSON API you drive with curl. Fine, and also: every desktop already has a WebDAV client built in.

app.MapWebDav("/dav", o =>
{
    o.RootPath = FileSystem.AppDataDirectory;
    o.AllowWrite = true;
})
.RequireAuthorization();

Point Finder or Windows Explorer at that and the app’s storage is a drive. RFC 4918 classes 1 and 2 — PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK/UNLOCK, the If header.

Class 2 is on by default and is not really optional, which I learned the way you would expect: Finder and the Windows redirector both mount a class 1 server read-only no matter what your options say. And a LOCK on a URL that does not exist yet has to create an empty resource for it, because that is what a Mac does when you save a new document — answer 404 there, which is what I did first, and the mount simply cannot be written to.

PROPFIND with Depth: infinity is refused by default. A missing Depth header means infinity per the spec, so it gets the same refusal. Walking a phone’s entire storage into one XML response is not a feature.

Two integrations, because the plumbing was already there

Mediator. If you already write Shiny.Mediator handlers, they can be endpoints:

[MediatorHttpGroup("/api/gadgets")]
public class GadgetHandlers : IRequestHandler<GetGadget, Gadget>
{
    [MediatorHttpGet("/{id:int}")]
    public Task<Gadget> Handle(GetGadget request, IMediatorContext ctx, CancellationToken ct) => …;
}

app.MapGeneratedMediatorEndpoints();

It is the Shiny.Mediator.AspNet shape without ASP.NET Core. The binding had to change to get here: the ASP.NET version uses [AsParameters]/[FromBody], which is reflection over a delegate’s parameters and annotated RequiresDynamicCode. Here a generator writes the binding member by member, so a contract that cannot be bound is a build error rather than a 500. An ICommand answers with a status code and no body; an IStreamRequest<T> becomes Server-Sent Events, which is a suspiciously good fit.

DocumentDb. And if the data lives in Shiny.DocumentDb, a document type is a whole REST resource in one line:

app.MapDocuments<Order>("/orders", o =>
{
    o.TypeInfo = AppJson.Default.Order;
    o.AllowFilterOn(x => x.Status, x => x.Total);
    o.Scope<ITenantContext>((tenant, _) => x => x.TenantId == tenant.TenantId);
})
.RequireAuthorization("orders");

List, by-id, count, create, replace, RFC 7396 merge-patch, delete, and a live SSE tail — with filtering, cursor paging, sparse fieldsets and ETag/If-Match. It is a port of the ASP.NET Core version onto this server, running the same engine, so the semantics are identical on both. A phone can serve its own database, and through a tunnel, so can anyone else reach it.

Two things I changed deliberately. MapDocuments returns a builder that fans RequireAuthorization across every route it registered, because this server attaches metadata per route rather than per group and I did not want adding an operation later to quietly leave one unprotected. And there is no reflection fallback when TypeInfo is unset — a fallback that works on your laptop and throws on a trimmed phone is worse than a clear error in both places.

Server-side scopes answer 404, not 403, for a document outside the scope. Telling a caller “that exists, you may not have it” is a leak dressed up as correctness.

The packages

Package What it is
Shiny.Net.HttpServer The server — protocols, routing, middleware, DI, static files, WebSockets, SSE, sessions, OpenAPI, CORS, rate limiting, IP filtering, content negotiation with XML/MessagePack/protobuf, tunnelling. Carries the tier 3 generator as an analyzer, so it runs in the compiler and never lands in your output
Shiny.Net.HttpServer.Jwt JWT auth on in-box crypto
Shiny.Net.HttpServer.Ssh SSH remote forwarding and quick tunnels
Shiny.Net.HttpServer.AzureRelay Azure Relay tunnel provider
Shiny.Net.HttpServer.Mcp MCP over Streamable HTTP, no ASP.NET Core required
Shiny.Net.HttpServer.Grpc gRPC and gRPC-Web — all four method shapes, marshalling supplied by you
Shiny.Net.HttpServer.WebDav A directory as an RFC 4918 class 1 & 2 mount
Shiny.Net.HttpServer.Mediator Shiny.Mediator handlers published as endpoints, bound at compile time
Shiny.Net.HttpServer.DocumentDb A Shiny.DocumentDb document type as a complete HTTP resource

The honest bits

Two packages are deliberately not AOT-clean, and I would rather say so here than have you find out during a Release build.

Shiny.Net.HttpServer.Ssh carries SSH.NET, which brings BouncyCastle and its own algorithm registries. Shiny.Net.HttpServer.AzureRelay drags in Azure.Identity, MSAL and IdentityModel. Both live in their own packages precisely so that the trade is yours to make: reference them and you accept the weight, skip them and the core server stays a few megabytes of clean AOT.

The other seven hold the line, including all four of the new ones — which is most of why gRPC and protobuf ask you for marshalling rather than discovering it. Shiny.Net.HttpServer.DocumentDb needs Shiny.DocumentDb 13.2.1 or newer, since that is the release carrying the public hosting surface it is built on.

This is also a beta. The API surface is where I want it and the test suite is real — around 1,260 tests, all of them against a live socket rather than an in-memory harness, because most of what a server can get wrong only exists at that boundary. But it has not had a thousand people hitting it in anger yet. If you put it somewhere interesting, I want to hear what broke.

And the standing warning, because a tunnel makes it matter: a quick tunnel hands a public HTTPS address to anyone who learns it, pointed at a server whose defaults were chosen for loopback. Put authentication in front of it before you open one, not after.

Go play

dotnet add package Shiny.Net.HttpServer

Docs are at shinylib.net/httpserver, source at github.com/shinyorg/httpserver. There is a MAUI sample in the repo that does the whole tour — serves a page, exposes a file browser, requires a password, runs an MCP server and puts the lot on the public internet behind a button.

Your phone is a perfectly good web server. It always was. It just needed something to run.


comments powered by Disqus