An authorization server issues tokens for a protected Model Context Protocol® (MCP) tool endpoint. OneModelContext handles OAuth; your application supplies login and consent.
dotnet add package RecursiveAI.OneModelContextRequires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.
using System.Security.Cryptography; using RecursiveAI.OneModelContext.Auth.Server; using RecursiveAI.OneModelContext.Http; using RecursiveAI.OneModelContext.Jose; using var key = ECDsa.Create(); using var auth = new AuthorizationServer(new() { Issuer = new("https://auth.example.com"), Resources = ["https://app.example.com/tools"], SigningKeys = [JsonWebSigningKey.FromECDsa(key, "demo")], ScopesSupported = ["read"], AuthorizeUser = (_, _) => ValueTask.FromResult( AuthorizationDecision.Deny()), }); await using var http = new HttpServer( async exchange => { await auth.HandleAsync(exchange); }); http.Start(); Console.WriteLine("Authorization server started."); Console.WriteLine($"Listening: {http.BaseAddress}"); Console.ReadLine();
Unzip the example, then dotnet run.
The snippet at the top prints Authorization server started. and its local listening address, then waits for Enter. It listens on IPv4 loopback at an available port; metadata is at /.well-known/oauth-authorization-server relative to the printed address. The example public URLs are metadata, not local bindings; configure the real public issuer and resource URLs in your deployed host. Its callback denies authorization until you implement login and consent.
The downloaded program is different: dotnet run prints Anonymous: 401 followed by Authorized: 42, as shown below, then exits.
The full program below starts a temporary listener, registers a demonstration client, then performs an authorization-code exchange and calls a protected calculator. Its callback stands in for an already signed-in user who approved the requested scope. It never opens a browser, uses an external account or prints a token. The key and in-memory store exist only for this run.
As the authorization-server guide states, “the server never renders a login page itself.” The anonymous connection throws AuthorizationException with status 401; the example catches it before trying the authorized connection.
The full program
using System.Net; using System.Security.Cryptography; using RecursiveAI.OneModelContext; using RecursiveAI.OneModelContext.Auth; using RecursiveAI.OneModelContext.Auth.Server; using RecursiveAI.OneModelContext.Hosting; using RecursiveAI.OneModelContext.Http; using RecursiveAI.OneModelContext.Jose; using RecursiveAI.OneModelContext.OAuth; using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256); AuthorizationServer? authorization = null; ResourceServer? resource = null; StreamableHttpEndpoint? endpoint = null; await using var listener = new HttpServer(async exchange => { if (authorization!.IsServerPath(exchange.Request.Path)) await authorization.HandleAsync(exchange); else if (exchange.Request.Path == "/tools" || resource!.IsMetadataPath(exchange.Request.Path)) await endpoint!.HandleAsync(exchange); else exchange.Response.StatusCode = 404; }); listener.Start(); var issuer = new Uri(listener.BaseAddress!.GetLeftPart(UriPartial.Authority)); var url = new Uri(issuer, "/tools"); var redirect = new Uri($"http://{IPAddress.Loopback}:9/callback"); using var auth = new AuthorizationServer(new AuthorizationServerOptions { Issuer = issuer, Resources = [url.AbsoluteUri], SigningKeys = [JsonWebSigningKey.FromECDsa(key, "demo")], ScopesSupported = ["calculate"], Store = new MemoryAuthorizationStore(), // Local demonstration only: stand in for a signed-in user and consent. AuthorizeUser = (request, _) => ValueTask.FromResult( AuthorizationDecision.Approve("alice", request.RequestedScopes)), }); authorization = auth; await auth.Store.SaveClientAsync(new RegisteredClient { ClientId = "demo-app", RedirectUris = [redirect.AbsoluteUri], Scopes = ["calculate"], CreatedAt = DateTimeOffset.UtcNow, }, CancellationToken.None); resource = new ResourceServer(new ResourceServerOptions(url, auth.CreateTokenValidator()) { AuthorizationServers = [issuer.GetLeftPart(UriPartial.Authority)], ScopesSupported = ["calculate"], RequiredScopes = ["calculate"], }); var server = new ContextServer("protected-calculator", "1.0.0"); new Calculator().AddTo(server); using var handler = new StreamableHttpEndpoint(server, new StreamableHttpOptions { ResourceServer = resource }); endpoint = handler; try { await using var anonymous = await ModelContext.ConnectAsync(ContextTransport.Http(url)); } catch (AuthorizationException ex) { Console.WriteLine($"Anonymous: {ex.StatusCode}"); } using var oauth = new OAuthClient(new OAuthClientOptions(url) { UserAgent = new DemoBrowser(redirect), PreRegistered = new PreRegisteredClient("demo-app", Issuer: issuer.GetLeftPart(UriPartial.Authority)), }); using var http = oauth.CreateHttpClient(); await using (var client = await ModelContext.ConnectAsync(ContextTransport.Http(url, http))) Console.WriteLine("Authorized: " + (await client.CallToolAsync("add", new { a = 2, b = 40 })).Text); // Simulates the browser redirect without opening a window or printing tokens. sealed class DemoBrowser(Uri redirect) : IAuthorizationUserAgent { public Uri RedirectUri => redirect; public async ValueTask<Uri> AuthorizeAsync(Uri url, CancellationToken cancellationToken) { using var browser = new HttpClient(new SocketsHttpHandler { AllowAutoRedirect = false }); using var response = await browser.GetAsync(url, cancellationToken); if (response.StatusCode != HttpStatusCode.Found) throw new InvalidOperationException("Authorization did not redirect."); return response.Headers.Location!; } } public partial class Calculator { /// <summary>Add two integers.</summary> [Tool] public int Add(int a, int b) => a + b; }
dotnet run prints:
Anonymous: 401 Authorized: 42
Variations
Keep server records across restarts of a single process. Replace the full program’s Store assignment with:
Store = new FileAuthorizationStore("oauth-demo.json"),The run prints the same two lines and creates oauth-demo.json. This stores authorization records, not the signing key; production also needs persistent signing keys. Don’t share this single-process file store between server instances.
Related recipes
The short answer
For an MCP authorization server in .NET (Model Context Protocol®), construct AuthorizationServer, route its owned paths to HandleAsync, and provide signing keys, allowed resources, supported scopes and an AuthorizeUser callback. Protect the tool endpoint separately with a ResourceServer that validates the issued tokens. Hosting token endpoints and enforcing access on a tool endpoint are different responsibilities.
How do I run an authorization server in .NET?
OneModelContext embeds the authorization protocol in your application. The snippet at the top creates a server with an ephemeral signing key and a local HTTP listener. It serves authorization metadata and endpoints, but its callback refuses authorization. It is a starting point for hosting, not a login implementation.
The full program goes further without depending on another service. It registers demo-app with an exact redirect URI, allows the calculate scope, and configures a resource server for the calculator URL. The client then discovers authorization information, gets a code through the demonstration browser callback, exchanges it and calls add. The anonymous request is refused before the authorized call succeeds.
Use this local example to understand the handoff points. For a deployed application, the issuer must describe the public HTTPS authorization service, and the resource must identify the actual protected endpoint. A local listener with example public URLs does not deploy either service at those URLs. In ASP.NET Core, the documented hosting alternative is MapAuthorizationServer; in other hosts, route only paths owned by the authorization server.
Does an authorization server provide a login page?
No. The authorization-server documentation explicitly keeps login and consent with the host. AuthorizeUser receives the client identity, redirect URI, requested scopes, resource and browser exchange. It can approve, deny, redirect to your login route or return an interaction response. Approval should represent both an authenticated user and that user’s consent.
The download approves a fixed demonstration subject so it can exercise a complete exchange unattended. Do not use that callback as a production sign-in policy. The smaller snippet denies instead. Neither example claims to authenticate a person.
After login, use the documented resume URL to return the browser to the pending authorization request. The callback then sees the request again and can approve the scopes the user accepted. An approval can narrow the requested scope set; it is not a way to add unrelated permissions.
How do I protect the tool endpoint with the issued tokens?
Configure ResourceServer with the protected resource URL and the embedded server’s CreateTokenValidator(), as the full program does. The endpoint uses that resource-server configuration when handling tool requests. The built-in validator also checks the revocation state documented for the embedded server.
Returning discovery metadata alone is not enforcement. The example’s first connection is deliberately anonymous and receives 401. Only the subsequent connection uses the OAuth-configured HTTP client. That contrast shows where token acquisition ends and protected resource access begins; it is not an assertion that any client with metadata may invoke a tool.
Keep an explicit scope allow-list. The documentation says interactive requests can receive any well-formed scope if neither the server nor the client constrains scopes. The example constrains both, then requires calculate on the resource. Client Credentials additionally needs a pre-registered authenticated client, an allowed grant type and an applicable scope allow-list; it does not stand in for an interactive user’s consent.
How do I persist authorization server state?
The default in-memory store belongs to one process and is lost on restart. The file-store variation writes authorization records atomically to one JSON file and is also single-process. Multiple instances require a database store implementing the documented atomic operations. Choose storage together with your deployment model rather than pointing several replicas at the same file.
Signing keys have their own lifecycle. The ephemeral key here is appropriate only to an example. Persist production keys, keep the private material private, and rotate them using the documented key management APIs. The JWKS endpoint publishes the public keys; it is not a backup of the private signing key. Keeping the authorization-record file does not preserve a newly generated key across restarts.
The embedded service also does not provide consent screens, browser CORS policy or endpoint rate limiting. Those remain host responsibilities. It is not an OpenID Connect provider and does not issue id_tokens, even though metadata is available at an OpenID discovery path. For the exact supported grants and endpoint behavior, use the authorization-server guide rather than assuming every identity-provider feature is included.