Authio.Mcp.AspNetCore 0.2.0

dotnet add package Authio.Mcp.AspNetCore --version 0.2.0
                    
NuGet\Install-Package Authio.Mcp.AspNetCore -Version 0.2.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Authio.Mcp.AspNetCore" Version="0.2.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Authio.Mcp.AspNetCore" Version="0.2.0" />
                    
Directory.Packages.props
<PackageReference Include="Authio.Mcp.AspNetCore" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Authio.Mcp.AspNetCore --version 0.2.0
                    
#r "nuget: Authio.Mcp.AspNetCore, 0.2.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Authio.Mcp.AspNetCore@0.2.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Authio.Mcp.AspNetCore&version=0.2.0
                    
Install as a Cake Addin
#tool nuget:?package=Authio.Mcp.AspNetCore&version=0.2.0
                    
Install as a Cake Tool

Authio.Mcp.AspNetCore

Protect an ASP.NET Core MCP server with Authio.

The MCP server is an OAuth 2.1 resource server; Authio is its authorization server. This package wires the server's half of that contract:

  • Token validation — JWT bearer against the realm's published keys: signature (asymmetric algorithms only: RS/PS/ES 256/384/512), issuer, audience (your resource identifier) and expiry.
  • Introspection (RFC 7662) — every token that passes local validation is checked with Authio, because only Authio knows whether it was revoked or its session ended. An active answer is cached per jti for 30 seconds.
  • Protected resource metadata (RFC 9728) — the document an MCP client reads to discover Authio and the scopes to request.
  • MCP-ready challenges — 401 with WWW-Authenticate: Bearer … resource_metadata="…" and 403 with error="insufficient_scope", scope="…".
  • Connected accounts — trade the caller's Authio token for the provider token of an account they connected (GitHub, Google…) through Authio's token exchange (RFC 8693).
  • Client authentication — introspection and the exchange authenticate as the server's own client with client_secret_basic, client_secret_post or private_key_jwt (RFC 7523).
  • Start-up checks — the realm's metadata is read when the application starts; a wrong base URL, realm or issuer stops it with a sentence instead of every token being refused later.

Targets .NET 8 and .NET 10. It does not depend on the MCP SDK; it works with any endpoint you protect.

Install

dotnet add package Authio.Mcp.AspNetCore
dotnet add package ModelContextProtocol.AspNetCore

Register the server in Authio

In the Authio console, in your realm:

  1. Create a resource server whose identifier is the public URL of your MCP endpoint, e.g. https://mcp.example.com/mcp, with a scope such as mcp:tools.
  2. Create a confidential client for the MCP server itself and set it as the resource server's introspection client. Its id goes in ClientId. It can authenticate with any of the three methods Authio accepts — client_secret_basic (the default), client_secret_post or private_key_jwt; see Client authentication:
    • with a secret, the secret goes in ClientSecret;
    • with private_key_jwt, register the client's public key in the console under the client's Security step, as a JWK Set or a JWKS URL, and give this server the private key.
  3. For connected accounts, add a connection provider (e.g. alias github) and allow the MCP clients that call you to use it.

Configure

appsettings.json (keep the secret in user-secrets or an environment variable: Authio__Mcp__ClientSecret):

{
  "Authio": {
    "Mcp": {
      "BaseUrl": "https://auth.example.com",
      "Realm": "demo",
      "ResourceUrl": "https://mcp.example.com/mcp",
      "ClientId": "mcp-server",
      "ClientSecret": "<secret>",
      "RequiredScopes": [ "mcp:tools" ],
      "ResourceName": "My MCP server"
    }
  }
}
Setting Meaning
BaseUrl Where Authio answers, without the realm. Must match the base URL Authio publishes (Auth:PublicBaseUrl): the issuer {BaseUrl}/realms/{Realm} is built from it.
Realm The realm the resource server and its clients live in.
ResourceUrl The public URL of the MCP endpoint. It is the resource identifier and the token's aud (scheme and host lower-cased, default port dropped, trailing slash trimmed — as Authio stores it).
ClientId The resource server's introspection client. Also used for the token exchange.
ClientAuthentication How that client authenticates: Basic (client_secret_basic, default), Post (client_secret_post) or PrivateKeyJwt (private_key_jwt).
ClientSecret The client's secret. Required with Basic and Post; never sent with PrivateKeyJwt.
PrivateKey PrivateKeyJwt only: the client's unencrypted private key in PEM — PKCS#8 (BEGIN PRIVATE KEY), PKCS#1 (BEGIN RSA PRIVATE KEY) or SEC1 (BEGIN EC PRIVATE KEY).
PrivateKeyId PrivateKeyJwt only, optional: the kid the assertion names, matching the key in the client's JWK Set.
SigningAlgorithm PrivateKeyJwt only, optional: RS256/RS384/RS512/PS256/PS384/PS512 for RSA, ES256/ES384/ES512 for EC on the matching curve. Default: RS256 for RSA, the curve's algorithm for EC (ES256 for P-256).
RequiredScopes Scopes every token must carry. Default: mcp:tools.
ResourceName Optional resource_name in the protected resource metadata.
IntrospectionCacheDuration How long an active introspection answer is trusted. Default: 00:00:30; 00:00:00 disables the cache.

Every setting can also be set in code: AddAuthioMcp(options => { … }) runs after the configuration binding.

Client authentication

Introspection and the connected-accounts exchange are calls this server makes as its own OAuth client. Both authenticate the same way, with the method set in ClientAuthentication; it must be one the client is allowed to use in Authio, or Authio answers invalid_client.

client_secret_basic (default) — the id and secret in an HTTP Basic header, each percent-encoded first:

{ "Authio": { "Mcp": { "ClientId": "mcp-server", "ClientSecret": "<secret>" } } }
Authio__Mcp__ClientId=mcp-server
Authio__Mcp__ClientSecret=<secret>

client_secret_post — the id and secret as client_id / client_secret in the form body:

{ "Authio": { "Mcp": { "ClientId": "mcp-server", "ClientAuthentication": "Post", "ClientSecret": "<secret>" } } }
Authio__Mcp__ClientAuthentication=Post
Authio__Mcp__ClientSecret=<secret>

private_key_jwt (RFC 7523) — no secret travels. For every request the server signs a fresh assertion with the client's private key: iss and sub = ClientId, aud = the realm's issuer ({BaseUrl}/realms/{Realm}), a unique jti, valid for 2 minutes. It is sent as client_assertion with client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer and client_id.

Create a key pair, keep the private key with the server, and register the public key on the client in the Authio console (Security step, as a JWK Set or a JWKS URL):

openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out mcp-server.key   # RSA → RS256
openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out mcp-server.key # or EC P-256 → ES256
openssl pkey -in mcp-server.key -pubout -out mcp-server.pub                          # the public half, to turn into a JWK
{
  "Authio": {
    "Mcp": {
      "ClientId": "mcp-server",
      "ClientAuthentication": "PrivateKeyJwt",
      "PrivateKey": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBg…\n-----END PRIVATE KEY-----",
      "PrivateKeyId": "mcp-server-2026",
      "SigningAlgorithm": "PS256"
    }
  }
}
Authio__Mcp__ClientAuthentication=PrivateKeyJwt
Authio__Mcp__PrivateKey="$(cat mcp-server.key)"
Authio__Mcp__PrivateKeyId=mcp-server-2026

PrivateKeyId and SigningAlgorithm are optional. When the key lives elsewhere — a vault, a certificate — hand the signing credentials over in code instead; they take precedence over PrivateKey, and the key's KeyId becomes the kid:

builder.Services.AddAuthentication().AddAuthioMcp(options =>
{
    options.ClientAuthentication = AuthioMcpClientAuthentication.PrivateKeyJwt;
    options.SigningCredentials = new SigningCredentials(
        new RsaSecurityKey(rsaFromVault) { KeyId = "mcp-server-2026" },
        SecurityAlgorithms.RsaSha256);
});

A missing key, a PEM that is not an unencrypted RSA or EC private key, or an algorithm the key cannot sign with stops the application at start-up, naming the setting.

Use with the official C# MCP SDK

using Authio.Mcp.AspNetCore.Defaults;
using Authio.Mcp.AspNetCore.Extensions;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddAuthentication().AddAuthioMcp();
builder.Services.AddCors(cors => cors.AddAuthioMcpPolicy());
builder.Services.AddHttpContextAccessor();
builder.Services
    .AddMcpServer()
    .WithHttpTransport(transport => transport.Stateless = true)
    .WithToolsFromAssembly();

var app = builder.Build();

app.UseCors();
app.UseAuthentication();
app.UseAuthorization();

app.MapAuthioProtectedResourceMetadata();
app.MapMcp("/mcp")
    .RequireAuthorization(AuthioMcpDefaults.Policy)
    .RequireCors(AuthioMcpDefaults.CorsPolicy);

app.Run();

MapAuthioProtectedResourceMetadata() serves the document at /.well-known/oauth-protected-resource{resource path} — for https://mcp.example.com/mcp that is /.well-known/oauth-protected-resource/mcp:

{
  "resource": "https://mcp.example.com/mcp",
  "authorization_servers": [ "https://auth.example.com/realms/demo" ],
  "scopes_supported": [ "mcp:tools" ],
  "bearer_methods_supported": [ "header" ],
  "resource_name": "My MCP server"
}

A tool that reads the caller and uses a connected account

using System.ComponentModel;
using Authio.Mcp.AspNetCore.Exchanges;
using Authio.Mcp.AspNetCore.Extensions;
using Authio.Mcp.AspNetCore.Models;
using ModelContextProtocol.Server;

[McpServerToolType]
public sealed class GitHubTools
{
    [McpServerTool(Name = "github_account", ReadOnly = true)]
    [Description("Shows which GitHub account the caller connected through Authio.")]
    public static async Task<string> GitHubAccount(
        IHttpContextAccessor accessor,
        IAuthioConnectionExchange exchange,
        [Description("Which connected account to use, when there are several.")] string? connectionId = null,
        CancellationToken cancellationToken = default)
    {
        var context = accessor.HttpContext!;
        var caller = context.GetAuthioCaller();          // sub, azp, scopes, org_id, org_alias, jti, exp
        var subjectToken = context.GetAuthioAccessToken()!; // the verified Authio access token

        var result = await exchange.ExchangeAsync(subjectToken, "github", connectionId, cancellationToken);

        return result switch
        {
            AuthioConnectionGranted granted =>
                $"{caller!.Subject} is {granted.ExternalAccount?.Login} on GitHub.", // use granted.AccessToken
            AuthioConnectionRequired required =>
                $"Connect your GitHub account first: {required.ConnectUri}",
            AuthioConnectionSelectionRequired selection =>
                "Pick one: " + string.Join(", ", selection.Connections.Select(c => $"{c.Id} ({c.ExternalLogin})")),
            AuthioConnectionRefused refused =>
                $"Authio refused ({refused.Error}, {refused.Code}): {refused.Description}",
            _ => "Unexpected result."
        };
    }
}

Return each refusal to the model as text the person can act on: the address where they connect the account, the accounts to choose from, or the reason Authio gave.

A runnable version lives in samples/Authio.Mcp.Sample.

Challenges

Situation Status WWW-Authenticate
No token 401 Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp"
Invalid, expired, foreign-audience or revoked token 401 Bearer error="invalid_token", error_description="…", resource_metadata="…"
Token without the required scopes 403 Bearer error="insufficient_scope", scope="mcp:tools", resource_metadata="…"

If Authio refuses the introspection request itself (wrong client credentials), the request fails with a 500 and the error is logged naming the settings to check: Authio:Mcp:ClientId, Authio:Mcp:ClientAuthentication and Authio:Mcp:ClientSecret — or Authio:Mcp:PrivateKey with private_key_jwt.

Browser-based MCP clients (MCP Inspector)

A browser client reads the protected resource metadata cross-origin and must read WWW-Authenticate from the 401 to find it. The metadata endpoint already answers any origin. For the MCP endpoint, register the CORS policy and add it to the endpoint, with app.UseCors() before app.UseAuthentication():

builder.Services.AddCors(cors => cors.AddAuthioMcpPolicy());
// …
app.UseCors();
app.MapMcp("/mcp").RequireAuthorization(AuthioMcpDefaults.Policy).RequireCors(AuthioMcpDefaults.CorsPolicy);

The policy allows any origin, the MCP headers (Authorization, Content-Type, Mcp-Session-Id, Mcp-Protocol-Version, Last-Event-ID) and exposes WWW-Authenticate and Mcp-Session-Id. If you use your own policy, expose WWW-Authenticate in it.

API

Member Purpose
AuthenticationBuilder.AddAuthioMcp(Action<AuthioMcpOptions>?) Registers the scheme, the policy, introspection, the exchange and the start-up check.
IEndpointRouteBuilder.MapAuthioProtectedResourceMetadata() Serves the RFC 9728 document.
CorsOptions.AddAuthioMcpPolicy(string name = AuthioMcpDefaults.CorsPolicy) CORS policy for browser MCP clients.
AuthioMcpDefaults.Policy / .AuthenticationScheme / .CorsPolicy / .HttpClientName Names to refer to.
AuthioMcpClientAuthentication (Basic, Post, PrivateKeyJwt) How the server authenticates to introspection and the exchange.
IAuthioConnectionExchange.ExchangeAsync(subjectToken, provider, connectionId?, ct) Connected-accounts token exchange.
HttpContext.GetAuthioAccessToken() The verified raw token (subject token for the exchange).
HttpContext.GetAuthioCaller() / ClaimsPrincipal.GetAuthioCaller() AuthioCaller (subject, client, scopes, organization, token id, expiry).
ClaimsPrincipal.GetAuthioScopes() / HasAuthioScopes(scopes) Scope checks, e.g. per tool.
IAuthioTokenIntrospector Replaceable introspection service.

All calls to Authio (metadata, keys, introspection, exchange) go through the named HttpClient AuthioMcpDefaults.HttpClientName, so you can add a proxy, a handler or resilience with services.AddHttpClient(AuthioMcpDefaults.HttpClientName)….

License

MIT

Product Compatible and additional computed target framework versions.
.NET net8.0 is compatible.  net8.0-android was computed.  net8.0-browser was computed.  net8.0-ios was computed.  net8.0-maccatalyst was computed.  net8.0-macos was computed.  net8.0-tvos was computed.  net8.0-windows was computed.  net9.0 was computed.  net9.0-android was computed.  net9.0-browser was computed.  net9.0-ios was computed.  net9.0-maccatalyst was computed.  net9.0-macos was computed.  net9.0-tvos was computed.  net9.0-windows was computed.  net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.2.0 75 10/8/2026
0.1.0 89 10/4/2026