Sign in with OAuth to a Model Context Protocol® (MCP) server, then call a tool.
dotnet add package RecursiveAI.OneModelContextRequires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.
using System.Diagnostics; using RecursiveAI.OneModelContext; using RecursiveAI.OneModelContext.OAuth; var url = new Uri(args.Length > 0 ? args[0] : "https://example.com/tools"); using var receiver = new LoopbackRedirectReceiver(OpenBrowser); using var oauth = new OAuthClient( new OAuthClientOptions(url) { UserAgent = receiver, PreRegistered = new("demo-app"), }); using var http = oauth.CreateHttpClient(); await using var client = await ModelContext.ConnectAsync( ContextTransport.Http(url, http)); Console.WriteLine((await client.CallToolAsync( "add", new { a = 2, b = 40 })).Text); static ValueTask OpenBrowser(Uri url, CancellationToken ct) { Process.Start(new ProcessStartInfo(url.AbsoluteUri) { UseShellExecute = true }); return ValueTask.CompletedTask; }
Download this example (.zip): unzip, then dotnet run.
Replace https://example.com/tools with your server’s URL (or pass that URL as the first argument to dotnet run --). Replace demo-app with your registered public client ID. Register receiver.RedirectUri with that issuer; the receiver uses a loopback /callback URL on a free port. This example calls an add tool with integer arguments a and b; choose a tool your server exposes.
The snippet opens the system browser, receives the authorization redirect, exchanges the code and calls the tool through the OAuth HTTP client. Against the bundled calculator, it prints:
42The full program
The download puts the client first in Program.cs; its embedded authorization server and calculator are in DemoServer.cs, shown separately below. Run dotnet run for a local exchange with no account, service key or browser window. It uses the real LoopbackRedirectReceiver; FollowLocalRedirect follows the demo issuer’s approval redirect over HTTP to that receiver. Run dotnet run -- --browser to use the system browser instead.
using System.Diagnostics; using RecursiveAI.OneModelContext; using RecursiveAI.OneModelContext.OAuth; // DemoServer.cs supplies a local issuer and a protected calculator. await using var server = new DemoServer(); using var receiver = new LoopbackRedirectReceiver( args.Contains("--browser") ? OpenBrowser : FollowLocalRedirect); await server.RegisterAsync(receiver.RedirectUri); ITokenStore store = new MemoryTokenStore(); using var oauth = new OAuthClient(new OAuthClientOptions(server.Url) { UserAgent = receiver, PreRegistered = new("demo-app", Issuer: server.Issuer), Store = store, }); using var http = oauth.CreateHttpClient(); try { await using var anonymous = await ModelContext.ConnectAsync( ContextTransport.Http(server.Url)); } catch (AuthorizationException ex) { Console.WriteLine($"Anonymous: {ex.StatusCode}"); } await using var client = await ModelContext.ConnectAsync( ContextTransport.Http(server.Url, http)); Console.WriteLine("Authorized: " + (await client.CallToolAsync( "add", new { a = 2, b = 40 })).Text); static ValueTask OpenBrowser(Uri url, CancellationToken ct) { Process.Start(new ProcessStartInfo(url.AbsoluteUri) { UseShellExecute = true }); return ValueTask.CompletedTask; } // No login UI in the local demo: follow its approval redirect to the real receiver. static async ValueTask FollowLocalRedirect(Uri url, CancellationToken ct) { using var browser = new HttpClient(); using var response = await browser.GetAsync(url, ct); response.EnsureSuccessStatusCode(); }
dotnet run prints:
Anonymous: 401 Authorized: 42
The demo issuer approves “alice” automatically; it is not a login system. As the authorization-server docs say, “you keep the users.”
Local server supplied in the download
This is DemoServer.cs. It registers the receiver’s actual redirect URI, issues tokens and protects the calculator endpoint. It uses a temporary signing key and an in-memory authorization store.
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; sealed class DemoServer : IAsyncDisposable { private readonly ECDsa key = ECDsa.Create(ECCurve.NamedCurves.nistP256); private readonly HttpServer listener; private readonly AuthorizationServer authorization; private readonly ResourceServer resource; private readonly StreamableHttpEndpoint endpoint; public Uri Url { get; } public string Issuer => authorization.Issuer; public DemoServer() { listener = new HttpServer(HandleAsync); listener.Start(); var issuer = new Uri(listener.BaseAddress!.GetLeftPart(UriPartial.Authority)); Url = new Uri(issuer, "/tools"); authorization = new AuthorizationServer(new AuthorizationServerOptions { Issuer = issuer, Resources = [Url.AbsoluteUri], SigningKeys = [JsonWebSigningKey.FromECDsa(key, "demo")], ScopesSupported = ["calculate"], Store = new MemoryAuthorizationStore(), // Local demo only: stand in for a signed-in user and consent. AuthorizeUser = (request, _) => ValueTask.FromResult( AuthorizationDecision.Approve("alice", request.RequestedScopes)), }); resource = new ResourceServer(new ResourceServerOptions( Url, authorization.CreateTokenValidator()) { AuthorizationServers = [Issuer], ScopesSupported = ["calculate"], RequiredScopes = ["calculate"], }); var calculator = new ContextServer("protected-calculator", "1.0.0"); new Calculator().AddTo(calculator); endpoint = new StreamableHttpEndpoint(calculator, new StreamableHttpOptions { ResourceServer = resource }); } public ValueTask RegisterAsync(Uri redirect) => authorization.Store.SaveClientAsync( new RegisteredClient { ClientId = "demo-app", RedirectUris = [redirect.AbsoluteUri], Scopes = ["calculate"], CreatedAt = DateTimeOffset.UtcNow, }, CancellationToken.None); private async ValueTask HandleAsync(IHttpExchange 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; } public async ValueTask DisposeAsync() { await listener.DisposeAsync(); endpoint.Dispose(); authorization.Dispose(); key.Dispose(); } } public partial class Calculator { /// <summary>Add two integers.</summary> [Tool] public int Add(int a, int b) => a + b; }
Variations
Keep tokens in a file. Replace ITokenStore store = new MemoryTokenStore(); in the full program with:
ITokenStore store = new FileTokenStore(Path.Combine( Environment.GetFolderPath( Environment.SpecialFolder.LocalApplicationData), "OneModelContextExample", "tokens.json")); Console.WriteLine(store.GetType().Name);
It prints the store type, then completes the same local authorization exchange:
FileTokenStore Anonymous: 401 Authorized: 42
Use this store with your real server to keep tokens between runs. The bundled demo deliberately changes its port and signing key at each start, so it is not a persistent sign-in service. Do not print or commit the token file.
Related recipes
- Build a Model Context Protocol® server in C#
- Connect to a Model Context Protocol® server in C#
- Host a Model Context Protocol® server on ASP.NET Core
- Serve the Model Context Protocol® over HTTP without ASP.NET
- Require OAuth sign-in on a Model Context Protocol® server
The short answer
For an MCP OAuth client in .NET (Model Context Protocol®), create OAuthClient, use its CreateHttpClient() result with ContextTransport.Http, and connect normally. The HTTP handler adds tokens and can answer an authorization challenge by completing the flow and replaying the request.
The download includes a local protected server, a pre-registered public client and a real loopback redirect receiver. Its output is a real anonymous rejection followed by a successful authorized tool call. Nothing contacts an external identity provider, and no token or reusable secret is printed.
How does OAuth authentication work for a protocol client?
A protected endpoint supplies a challenge. The OAuth client discovers protected-resource metadata and authorization-server metadata, obtains client registration information, and performs authorization code with PKCE S256. It then sends the access token to the protected endpoint. The OAuth client guide documents discovery checks, registration order and error handling.
This differs from simply setting an Authorization header. CreateHttpClient supplies the integration that can react to 401 or 403 and replay the request after authorization. The AuthorizeRequestAsync decorator only decorates requests; it cannot itself answer a challenge. Use the handler when you want automatic challenge handling, rather than expecting a decorator to launch a sign-in flow.
In the full program, the unsigned connection first demonstrates that protection is active. The second connection uses the OAuth HTTP client and succeeds. The calculator is the same tool on both attempts; only the caller’s authorization changed.
How do I register an OAuth client?
The example pre-registers demo-app in the local authorization server’s store, including a redirect URI and allowed scope. The OAuth options name that client and bind it to the known issuer. It is a public client, so there is no client secret to copy into configuration.
For your own application, register the actual redirect URI with the issuer. The library also supports Client ID Metadata Documents when the server advertises that mechanism. Registration is not interchangeable with discovery: knowing a resource URL does not mean an arbitrary client ID or redirect is accepted.
The client docs describe the fallback order and explain that credentials are keyed by issuer. Bind a known pre-registered client to its issuer rather than treating a client ID as a credential for every server. Dynamic registration exists for compatibility but is deprecated by the current authorization specification; this example does not need it.
How do I handle the browser redirect in .NET?
For a desktop or console application, LoopbackRedirectReceiver supplies a local callback listener; your application provides the function that opens the authorization URL in a browser. Its redirect URI must be registered or listed in the client metadata document.
The top snippet supplies OpenBrowser, which launches the system browser with Process.Start. The download uses that same function with --browser. Its default, window-free mode follows the local issuer’s redirect using HttpClient; the redirect still arrives at the real receiver and must match the pending authorization request. That shortcut works only because the demo issuer automatically approves a test user.
For a real application, use the documented receiver and a real user sign-in flow. Keep the issuer’s login UI out of the protocol client: the client opens the authorization URL and receives the result; the issuer authenticates the user.
How do I stay signed in between runs?
The default token store is in memory, which suits this short demonstration. Use FileTokenStore or another documented ITokenStore implementation when sessions must survive process restarts. The OAuth client handles refresh through its configured store rather than requiring the tool-calling code to read and print tokens.
If authorization fails, distinguish the errors. AuthorizationException describes a refused protocol request; OAuthException describes a failed authorization flow, such as metadata, state, issuer or token-exchange validation. Do not turn those errors into a silent anonymous retry loop. Follow the client guide for the flow you actually host.