.NET MAUI
This is the case the library exists for: ASP.NET Core does not run in a .NET MAUI app, and this does. A phone hosting its own API, its own configuration page, its own file access — and, through a tunnel, reachable from anywhere.
Wiring
Section titled “Wiring”builder.Services.AddHttpServer( options => { // Any, not loopback: the point is for another device to reach this one. // Port 0 lets the OS pick, so two copies of the app never collide. options.Address = IPAddress.Any; options.Port = 0; }, server => { server.UseAuthentication(); server.UseEmbeddedFiles(typeof(MauiProgram).Assembly, "MyApp.wwwroot"); server.MapMyAppEndpoints(); },
// Started by the UI instead, so the app does not open a port before anyone asked it to. autoStart: false);Then, from a toggle:
public sealed class ShareViewModel(HttpServer server){ public async Task StartAsync() { await server.StartAsync(); this.LocalUrl = server.ListenUrl; }}server.StateChanged gives the UI its transitions without polling. See
Hosting & Lifecycle.
Platform configuration
Section titled “Platform configuration”Each of these fails silently rather than with an error message, which is why they are worth listing.
<!-- Platforms/iOS/Info.plist --><key>NSLocalNetworkUsageDescription</key><string>This app serves a page to other devices on your network.</string>iOS 14+ gates anything touching the local network behind a permission prompt, and that includes serving on it. Without the key the app is denied without ever being asked.
Mac Catalyst
Section titled “Mac Catalyst”<!-- Platforms/MacCatalyst/Entitlements.plist --><key>com.apple.security.network.server</key><true/>Mac Catalyst runs sandboxed and the sandbox grants outgoing connections only. Without this the bind is refused and the server simply never appears.
Android
Section titled “Android”<uses-permission android:name="android.permission.INTERNET" />Already in the MAUI template.
HTTP or HTTPS?
Section titled “HTTP or HTTPS?”Plain HTTP on the local network is the pragmatic default, because a self-signed certificate has to be installed and trusted per device before a browser will accept it — see TLS & Certificates. Pair it with:
- iOS:
NSAllowsLocalNetworkingunderNSAppTransportSecurity, for the app’s ownHttpClienttalking to another device. - Android: a
network_security_configentry permitting cleartext to the addresses involved.
If the connection leaves the network, terminate TLS at the tunnel with a real certificate and let the server speak cleartext behind it.
Serving a UI
Section titled “Serving a UI”A packaged app has no wwwroot on disk, so the assets travel inside the assembly:
<ItemGroup> <EmbeddedResource Include="wwwroot\**" /></ItemGroup>server.UseEmbeddedFiles(typeof(MauiProgram).Assembly, "MyApp.wwwroot");A whole Blazor WebAssembly app works the same way.
Passwords on a device
Section titled “Passwords on a device”AddBasic<TValidator> is the shape that fits an app whose credentials are editable on a settings
screen: the server asks the validator on every request rather than copying a list at startup, so a
change takes effect immediately with nothing restarted.
builder.Services.AddAuthentication().AddBasic<CredentialStore>(o => o.Realm = "My device");Keep the password in SecureStorage — the keychain on Apple platforms, the encrypted preference
store on Android — and generate one on first run. A well-known default on something reachable
from the internet is the same as no password.
Trimming and AOT
Section titled “Trimming and AOT”iOS has no JIT, so a MAUI app on it is trimmed by definition and reflection-based serialization is not an option. That is the constraint the whole library is built around, and there is exactly one thing you have to bring:
[JsonSerializable(typeof(DeviceSummary))][JsonSerializable(typeof(Note))]public partial class ApiJsonContext : JsonSerializerContext;
JsonTypeInfoRegistry.Register(ApiJsonContext.Default);The endpoint generator emits that registration for you and warns (SWS006) about any type it does not cover. Register it by hand only when you are not using the generator.
Two packages need a word here:
Shiny.Net.HttpServer.Sshis pure managed code and runs on iOS and Android — which is why quick tunnels are the tunnelling story for a phone.Shiny.Net.HttpServer.AzureRelayis deliberately not AOT-clean, because its SDK drags in Azure.Identity, MSAL and IdentityModel.
Going public from a phone
Section titled “Going public from a phone”builder.Services.AddQuickTunnel(); // pinggy: no account, nothing installedpublic sealed class ShareViewModel{ public ShareViewModel(QuickTunnel tunnel) { tunnel.PropertyChanged += (_, _) => MainThread.BeginInvokeOnMainThread(() => { this.Url = tunnel.PublicUrl; this.Status = tunnel.State.ToString(); }); }}Three things about this that are not decoration:
QuickTunnelraises its changes on a background thread. MAUI will not marshal them for you.- A free tunnel assigns a different address on every reconnect, and a phone reconnects whenever
it changes network. Bind to
PublicUrl; do not read it once. When the connection drops it goes null and the state readsReconnecting— showing nothing beats showing a link that no longer works. StartAsynccan return null, and it takes a cancellation token. Opening a tunnel talks to a machine on the other side of the internet; it can sit there for seconds and it can come back with no address at all (StategoesFailed,LastErrorsays why). Do not gate every control on a singleIsBusyflag while you wait — leave the user a way to cancel, or the screen locks itself out of its own waiting state.
An MCP server in an app
Section titled “An MCP server in an app”The MCP SDK’s own HTTP transport is an ASP.NET Core package, so this is the part that genuinely cannot be done any other way:
builder.Services .AddMcpServer(o => o.ServerInfo = new Implementation { Name = "my-device", Version = "1.0.0" }) .WithTools<DeviceTools>(ApiJsonContext.Default.Options) .WithHttpTransport(o => o.MaxSessions = 8);
// in configureServer:server.MapMcp();See Model Context Protocol — including why the JsonSerializerContext is passed
to WithTools rather than left to reflection.
The sample
Section titled “The sample”samples/Sample.Maui in the repository is all of the above: an embedded page, a small JSON API, a
file browser over the app’s own storage, an MCP server, and a public URL
— everything but /ping behind a Basic password that is editable in the app. It is built for
Android, iOS and Mac Catalyst.
It has three tabs, and the middle one is the part worth stealing. A ~40-line IHttpMiddleware sits
at the front of the pipeline — ahead of authentication, so rejected requests are recorded too — and
copies each exchange out of the pooled HttpContext: timestamp, method and target, protocol, status,
duration, peer address, whether it arrived through the tunnel, the authenticated user, and every
header in both directions. Tapping one shows the whole exchange. It is the fastest way to see what a
client is actually sending when an endpoint is not behaving.


