Skip to content
Shiny.Net.HttpServer v1 - A lightweight feature rich HTTP Server - Tunnels, Websockets, AOT, ASPNET Featureset, & Works EVERYWHERE!Let me see!

.NET MAUI

Frameworks
.NET MAUI
Operating Systems
Android
iOS
macOS
Windows

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.

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.

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.

<!-- 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.

<uses-permission android:name="android.permission.INTERNET" />

Already in the MAUI template.

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: NSAllowsLocalNetworking under NSAppTransportSecurity, for the app’s own HttpClient talking to another device.
  • Android: a network_security_config entry 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.

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.

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.

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.Ssh is pure managed code and runs on iOS and Android — which is why quick tunnels are the tunnelling story for a phone.
  • Shiny.Net.HttpServer.AzureRelay is deliberately not AOT-clean, because its SDK drags in Azure.Identity, MSAL and IdentityModel.
builder.Services.AddQuickTunnel(); // pinggy: no account, nothing installed
public 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:

  • QuickTunnel raises 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 reads Reconnecting — showing nothing beats showing a link that no longer works.
  • StartAsync can 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 (State goes Failed, LastError says why). Do not gate every control on a single IsBusy flag while you wait — leave the user a way to cancel, or the screen locks itself out of its own waiting state.

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.

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.