Recursive AI

Require OAuth sign-in on a server

OneModelContext is a free, open-source .NET SDK for the Model Context Protocol® from Recursive AI. MIT or Apache-2.0, no native dependencies.

Require OAuth access tokens for Model Context Protocol® (MCP) tool calls.

Terminal
dotnet add package RecursiveAI.OneModelContext

Requires .NET 10 (net10.0); .NET 9 and earlier aren’t supported.

C#
using RecursiveAI.OneModelContext;
using RecursiveAI.OneModelContext.Auth;
using RecursiveAI.OneModelContext.Hosting;
using RecursiveAI.OneModelContext.Jose;
 
var url = new Uri("https://example.com/tools");
var validator = new JwtTokenValidator(
    "https://example.com",
    new HttpJwksProvider(
        new Uri("https://example.com/jwks")));
var protection = new ResourceServer(
    new ResourceServerOptions(url, validator));
using var endpoint = new StreamableHttpEndpoint(
    new ContextServer("protected", "1.0.0"),
    new StreamableHttpOptions
    { ResourceServer = protection });
Console.WriteLine("Protected endpoint configured.");

Download this example (.zip): unzip, then dotnet run.

The download prints Anonymous: 401, then Authorized: 42.

The snippet at the top attaches bearer-token validation to an endpoint. It only configures objects: it does not bind a listener or fetch signing keys from the placeholder URLs. The full program below instead runs an embedded local authorization server, refuses an anonymous connection and accepts an authorized tool call.

Running the snippet prints:

Text
Protected endpoint configured.

The full program

The download starts an authorization server and a protected resource endpoint on one local listener. It tries an anonymous connection, then signs in a pre-registered public client with authorization code and PKCE and calls the tool.

C#
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:

Text
Anonymous: 401
Authorized: 42

The demo approves “alice” automatically; it is not a login system. As the authorization-server docs say, “you keep the users.”

Variations

Change the arguments. Replace the tool call inside the full program with:

C#
Console.WriteLine((await client.CallToolAsync(
    "add", new { a = 1, b = 41 })).Text);
Text
42

The short answer

To protect an MCP server with OAuth in C# (Model Context Protocol®), place a ResourceServer on the HTTP endpoint. Configure its resource URL, authorization server and token validator. OneModelContext also includes an AuthorizationServer when your application should issue its own tokens rather than use an external identity provider.

The downloadable example runs both pieces locally. It refuses an anonymous connection, then accepts a client that completed an authorization-code exchange. It does not ask you to create an account or provide a real signing key: a temporary signing key is generated for the demonstration.

Can protocol servers use OAuth for authentication and authorization?

Yes. The resource server checks bearer access tokens before tool dispatch and supplies protected-resource metadata and challenges. The authorization server handles the OAuth endpoints. Your application remains responsible for its users, their sign-in and their consent.

Those responsibilities are intentionally separate. A token validator determines whether an access token is acceptable for this resource. An authorization callback determines whether the user should be allowed to grant the requested access. Putting an automatic approval callback into production would remove the user check, even if every cryptographic step still worked.

The sample’s AuthorizeUser callback approves “alice” only to keep the exchange local and reproducible. Replace it with the host’s real authenticated user and consent decision. The authorization-server documentation describes Approve, Deny, Interact and Redirect, including returning to the authorization request after sign-in.

How do I add authentication to a server?

Start with the resource URI. Configure it, rather than deriving it from the incoming Host header. The example uses the locally bound endpoint URL. CreateTokenValidator connects the embedded authorization server to the resource server’s validation path, and the endpoint receives that resource server through StreamableHttpOptions.

Set the scopes your authorization server supports and the scopes the resource requires. Here both sides use calculate. The registered public client is also allowed that scope. The server docs warn that without a scope allow-list an interactive request can be granted any well-formed scope string. Scope configuration is therefore part of authorization, not merely documentation for a consent screen.

The listener sends authorization paths to AuthorizationServer.HandleAsync and sends the tool path and protected-resource metadata paths to StreamableHttpEndpoint. Omitting metadata routing can leave a token-protected tool endpoint that clients cannot discover how to sign in to.

What happens when a client is not authorized?

The example’s anonymous connection receives 401. The resource-server guide distinguishes missing or invalid tokens from insufficient scope: the latter receives 403 with an insufficient_scope challenge and the required scope. An unavailable issuer or introspection endpoint can instead produce 503. These are HTTP authorization outcomes, not calculator errors.

After the challenge, the OAuth client discovers metadata and performs authorization code with PKCE S256. The local test browser returns the authorization redirect without opening a window; the library then exchanges the code and sends the access token on the retried request. No token is printed.

For an actual service, persist authorization state with a documented store and keep signing keys stable across restarts. The in-memory store and newly generated key in this download intentionally last only for the process. They demonstrate the protocol, not durable sessions. The store guide describes the available persistence choices and their responsibilities.

All onemodelcontext recipes