Authio.Mcp.AspNetCore
0.5.0
dotnet add package Authio.Mcp.AspNetCore --version 0.5.0
NuGet\Install-Package Authio.Mcp.AspNetCore -Version 0.5.0
<PackageReference Include="Authio.Mcp.AspNetCore" Version="0.5.0" />
<PackageVersion Include="Authio.Mcp.AspNetCore" Version="0.5.0" />
<PackageReference Include="Authio.Mcp.AspNetCore" />
paket add Authio.Mcp.AspNetCore --version 0.5.0
#r "nuget: Authio.Mcp.AspNetCore, 0.5.0"
#:package Authio.Mcp.AspNetCore@0.5.0
#addin nuget:?package=Authio.Mcp.AspNetCore&version=0.5.0
#tool nuget:?package=Authio.Mcp.AspNetCore&version=0.5.0
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 is the MCP layer on
top of Authio.AspNetCore, which does the token work:
- Token validation (from
Authio.AspNetCore) — JWT bearer: signature with the client's access token algorithm (AccessTokenSignedResponseAlg; left out, the realm's asymmetric keys, neverHS*), issuer, audience (your resource identifier), expiry and token type (headertypat+jwt, RFC 9068; ID and refresh tokens are refused). Nested JWE encrypted to your client is decrypted with its key, within thealgandencthe client is registered for. Introspection (RFC 7662), when asked for, checks every token with Authio (ActiveCheck), or reads opaque tokens entirely from Authio (IntrospectionOnly). - The caller (from
Authio.AspNetCore) —HttpContext.GetAuthioUser(): whether a person, a client, a consumer or an account credential is calling, roles, permissions, scopes, resource access, organization, how the person signed in (acr/amr), delegation and impersonation. - Protected resource metadata (RFC 9728) — always published, so every
401carriesresource_metadata, as the MCP authorization specification requires. - Authorization per tool — put
[AuthioScope],[AuthioRole],[AuthioPermission],[AuthioCallerKind],[AuthioOrganization],[AuthioClient]or a step-up attribute on a tool. Atools/callthe caller may not make is answered at the HTTP layer, before the tool runs:403withinsufficient_scopeand the missing scope (the MCP scope challenge),403for a role, permission or caller kind, or401withacr_values/max_ageandresource_metadatafor a step-up (RFC 9470).tools/listonly lists the tools the caller may call. - Browser MCP clients — a CORS policy for the MCP Inspector and the like.
Connected accounts (trading the caller's token for the provider token of an account they connected) and the server's
own client authentication (its TokenEndpointAuthMethod: client_secret_basic, client_secret_post,
private_key_jwt or none) come from Authio.Client, registered for you.
Configure it the way the resource server and its client are registered in Authio. The package assumes nothing the Authio console configures: each setting is an option, and a setting left out is off, as in NextAuth — no scope required, no introspection, nothing decrypted. What stays fixed is what the MCP authorization specification requires (see What the MCP specification fixes).
Targets .NET 8 and .NET 10, with the official C# MCP SDK (ModelContextProtocol.AspNetCore).
Install
dotnet add package Authio.Mcp.AspNetCore
It brings Authio.AspNetCore, Authio.Client and ModelContextProtocol.AspNetCore with it.
Register the server in Authio
In the Authio console, in your realm:
- 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 asmcp:tools, and list the MCP clients that call you under Authorized clients. - Create a confidential client for the MCP server itself and set it as the resource server's introspection
client — Authio reports a token addressed to the resource server as active only to that client. Its id goes in
ClientId; its Token endpoint auth method goes inTokenEndpointAuthMethod, with a secret (ClientSecret) or, forprivate_key_jwt, the private key (register its public key on the client's Security step). - For connected accounts, give that client the token exchange grant, add a connection provider (e.g. alias
github) and allow the MCP clients to use it.
Configure
Every setting is read from Authio:Mcp, under the names every Authio library shares
(conformance/options.md). Keep secrets in user-secrets or environment variables
(Authio__Mcp__ClientSecret).
{
"Authio": {
"Mcp": {
"Authority": "https://auth.example.com",
"Realm": "demo",
"Resource": "https://mcp.example.com/mcp",
"ClientId": "mcp-server",
"TokenEndpointAuthMethod": "Basic",
"ClientSecret": "<secret>",
"RequiredScopes": [ "mcp:tools" ],
"Introspection": "ActiveCheck",
"ResourceName": "My MCP server"
}
}
}
| Setting | Left out | Meaning |
|---|---|---|
Authority + Realm, or Issuer |
Required | Where the realm is: the issuer is {Authority}/realms/{Realm}, or Issuer when set. It must match what Authio publishes. |
Resource |
Required | The public URL of the MCP endpoint: the resource identifier, the token's aud, and resource in the metadata. |
Audiences |
None | Other audiences accepted besides Resource. |
ClientId |
None; required with Introspection, a client credential or TokenEndpointAuthMethod |
The resource server's introspection client, also the client of the connected-accounts exchange. A server that validates locally and exchanges nothing needs none. |
TokenEndpointAuthMethod |
Follows the credentials | The client's Token endpoint auth method, which Authio matches exactly: Basic (client_secret_basic), Post (client_secret_post), PrivateKeyJwt (private_key_jwt) or None. Left out, Basic with a secret, PrivateKeyJwt with only a private key, None with neither. |
ClientSecret |
None | The client's secret, with Basic and Post. AuthioOptions.ClientSecretProvider (in code) reads a rotated secret on every use. |
ClientPrivateKey / ClientPrivateKeyPath |
None | With PrivateKeyJwt: the client's private key in PEM (PKCS#8, PKCS#1 or SEC1), or the path of the file holding it (re-read when it changes). |
ClientKeyId |
None | The kid the client assertion names. |
ClientSigningAlgorithm |
The one the key implies | The assertion's algorithm, RS/PS/ES 256/384/512. |
AccessTokenSignedResponseAlg |
RS256/384/512, PS256/384/512, ES256/384/512, never HS* |
The client's access token signing algorithm in Authio, one value ("RS256") or a list. HS256, HS384 and HS512 are verified with ClientSecret; a server without the secret uses Introspection: IntrospectionOnly instead. |
ClientEncryptionKey / ClientEncryptionKeyPath |
Nothing decrypted | The private half of the client's encryption key (JWK or PEM), for access tokens Authio encrypts to it. |
EncryptionAlgorithms / EncryptionMethods |
RSA-OAEP, RSA-OAEP-256; the CBC-HS methods |
The JWE alg and enc accepted, as registered on the client. A128GCM, A192GCM and A256GCM only when listed. |
AllowLegacyEncryptionAlgorithms |
false |
Accept JWEs wrapped with RSA1_5. |
Introspection |
None: local validation only |
ActiveCheck checks a token that passes local validation with Authio, so a revoked token or an ended session is refused before it expires; IntrospectionOnly reads opaque tokens (a client whose access token format is Reference) from Authio's answer. Both need the introspection client. |
IntrospectionCacheDuration |
00:00:30 |
How long an active answer is reused, never past the token's expiry; 00:00:00 turns the cache off. |
RequiredScopes |
No scope required | Scopes every token must carry, all of them, to reach the endpoint. List the resource server's scopes this server insists on, such as mcp:tools. |
ResourceScopes |
RequiredScopes |
scopes_supported in the metadata; left out when both are empty. |
ResourceName / ResourceDocumentation |
None | resource_name and resource_documentation in the metadata. |
ClockSkew, RejectNonAccessTokens, RequireHttps, DiscoveryCacheDuration, HttpTimeout |
As in the SDK | As in Authio.AspNetCore and Authio.Client. |
A missing or invalid setting stops the application at start-up with the setting named. In code, the settings are
AuthioOptions (realm and client), AuthioJwtBearerOptions named AuthioJwtBearerDefaults.AuthenticationScheme
(validation and metadata) and AuthioMcpOptions (required scopes):
builder.Services.AddAuthentication().AddAuthioMcp(options => options.RequiredScopes = ["mcp:tools"]);
builder.Services.Configure<AuthioOptions>(options =>
{
options.TokenEndpointAuthMethod = AuthioTokenEndpointAuthMethod.PrivateKeyJwt;
options.ClientSigningCredentials = credentialsFromVault;
});
builder.Services.Configure<AuthioJwtBearerOptions>(AuthioJwtBearerDefaults.AuthenticationScheme, options =>
options.Introspection = AuthioIntrospectionMode.IntrospectionOnly);
AddAuthioMcp(IConfiguration) reads another section instead of Authio:Mcp.
What the MCP specification fixes
Three things are not settings, because the MCP authorization specification requires them rather than any client or resource server setting in Authio:
| Fixed | Why |
|---|---|
authorization_servers is [issuer], the realm's issuer |
The specification requires the protected resource metadata to name the authorization server an MCP client must use. For an Authio-protected server that is the realm that issues its tokens; any other value would send the client to a server whose tokens are refused (iss must be the realm). |
bearer_methods_supported is ["header"] |
The specification requires MCP clients to send the token in the Authorization: Bearer header and forbids it in the query string; the package reads only the header. Authio has no other method to configure (no DPoP, no mTLS). |
Every 401 carries resource_metadata |
The specification requires the server to point the client at its protected resource metadata through WWW-Authenticate when it answers 401; that is how the client discovers Authio. The metadata is therefore always published, and 403 insufficient_scope carries it too. |
Use with the official C# MCP SDK
using Authio.AspNetCore.Extensions;
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)
.AddAuthorizationFilters()
.WithToolsFromAssembly();
var app = builder.Build();
app.UseCors();
app.UseAuthentication();
app.UseAuthorization();
app.MapAuthioProtectedResourceMetadata().RequireCors(AuthioMcpDefaults.CorsPolicy);
app.MapMcp("/mcp")
.RequireAuthioMcp()
.RequireCors(AuthioMcpDefaults.CorsPolicy);
app.Run();
AddAuthioMcp()registersAuthio.AspNetCore's bearer scheme (AuthioJwtBearerDefaults.AuthenticationScheme), the Authio client, the Authio policy provider and result handler (AddAuthioAuthorization), and theAuthioMcpDefaults.Policypolicy (RequiredScopes; with none, any authenticated caller).MapAuthioProtectedResourceMetadata()(fromAuthio.AspNetCore) serves the metadata at/.well-known/oauth-protected-resource{path of Resource}—/.well-known/oauth-protected-resource/mcphere.RequireAuthioMcp()requiresAuthioMcpDefaults.Policyon the MCP endpoint and authorizes eachtools/callagainst its tool.AddAuthorizationFilters()(from the MCP SDK) filterstools/listwith the same attributes.
{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": [ "https://auth.example.com/realms/demo" ],
"bearer_methods_supported": [ "header" ],
"scopes_supported": [ "mcp:tools" ],
"resource_name": "My MCP server"
}
Authorization per tool
using System.ComponentModel;
using Authio.AspNetCore.Attributes;
using Authio.AspNetCore.Enums;
using ModelContextProtocol.Server;
[McpServerToolType]
public sealed class FileTools
{
[McpServerTool(Name = "write_file")]
[Description("Writes a file.")]
[AuthioScope("files:write")] // 403 insufficient_scope, scope="files:write"
public static string Write(string path, string content) => "…";
[McpServerTool(Name = "purge")]
[Description("Purges the bucket.")]
[AuthioRole("admin")] // 403
[AuthioRecentAuthentication(300)] // 401 insufficient_user_authentication, max_age=300
public static string Purge() => "…";
[McpServerTool(Name = "sync")]
[Description("Runs the nightly sync.")]
[AuthioCallerKind(AuthioCallerKind.Client)] // machines only
public static string Sync() => "…";
}
[AuthioPermission("invoice", "write")], [AuthioOrganization], [AuthioClient("…")],
[AuthioAuthenticationLevel(2)], [AuthioAuthenticationMethod("hwk")] and [Authorize(Policy = "…")] work the same
way. For the whole endpoint, chain the requirement after RequireAuthioMcp():
app.MapMcp("/mcp").RequireAuthioMcp().RequireAuthioAuthenticationLevel(2, TimeSpan.FromHours(1));
A tool registered only for one session (ConfigureSessionOptions) is not known to RequireAuthioMcp(); the MCP
SDK's filter still refuses it, with a JSON-RPC error.
A tool that reads the caller and uses a connected account
using System.ComponentModel;
using Authio.AspNetCore.Extensions;
using Authio.Client.Clients;
using Authio.Client.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,
IAuthioTokenClient tokens,
[Description("Which connected account to use, when there are several.")] string? connectionId = null,
CancellationToken cancellationToken = default)
{
var context = accessor.HttpContext!;
var user = context.GetAuthioUser()!; // kind, subject, client, scopes, roles, org, acr/amr, act
var subjectToken = context.GetAuthioAccessToken()!; // the verified Authio access token
var result = await tokens.ExchangeForConnectionAsync(subjectToken, "github", connectionId, cancellationToken);
return result switch
{
AuthioConnectionGranted granted => $"{user.Subject} is {granted.ExternalAccount?.Login} on GitHub.",
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.ErrorCode}): {refused.ErrorDescription}",
_ => "Unexpected result."
};
}
}
Return each refusal to the model as text the person can act on.
A runnable version lives in samples/Authio.Mcp.Sample, with one settings file per
client authentication method; its README walks through the Authio console,
running it, and connecting the MCP Inspector or Claude Code.
Challenges
| Situation | Status | WWW-Authenticate |
|---|---|---|
| No token | 401 | Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource/mcp", scope="mcp:tools" |
Invalid, expired, foreign-audience or undecryptable token; revoked, with Introspection |
401 | Bearer resource_metadata="…", error="invalid_token", error_description="…", scope="mcp:tools" |
Token without one of RequiredScopes, or without a tool's scope |
403 | Bearer error="insufficient_scope", error_description="…", scope="…", resource_metadata="…" |
| A tool's role, permission, caller kind, organization or client not met | 403 | — |
| A sign-in too old or too weak (endpoint or tool) | 401 | Bearer error="insufficient_user_authentication", error_description="…", acr_values="…", max_age=…, resource_metadata="…" |
With Introspection, Authio refuses this server's client (invalid_client, unauthorized_client) |
500 | — |
With Introspection, Authio cannot be reached, times out or fails (5xx) |
503 | — |
The table is conformance/challenges.json of authio-node, which ChallengeConformanceTests runs through the MCP
endpoint; every 401, the step-up one included, names resource_metadata (RFC 9728 §5.1). The 401 for a missing
or invalid token also names RequiredScopes as scope, separated by spaces (left out when none is required), so the
MCP client asks for them rather than for every scope of scopes_supported. When introspection fails the token is not
at fault, so the answer is 500 or 503 with no challenge: an MCP client told invalid_token would sign in again in
a loop. The cause is logged.
Browser-based MCP clients (MCP Inspector)
A browser client reads the metadata cross-origin and must read WWW-Authenticate from the 401. Register the CORS
policy, call app.UseCors() before app.UseAuthentication(), and add .RequireCors(AuthioMcpDefaults.CorsPolicy)
to both the metadata and the MCP endpoint. The policy allows any origin (bearer tokens travel in a header, not in
cookies), the MCP headers (Authorization, Content-Type, Mcp-Session-Id, Mcp-Protocol-Version,
Last-Event-ID), and exposes WWW-Authenticate and Mcp-Session-Id.
API
| Member | Purpose |
|---|---|
AuthenticationBuilder.AddAuthioMcp(Action<AuthioMcpOptions>?) |
Reads Authio:Mcp and registers validation, the client, the policies and the result handler. |
AuthenticationBuilder.AddAuthioMcp(IConfiguration, Action<AuthioMcpOptions>?) |
The same, from another section. |
IEndpointConventionBuilder.RequireAuthioMcp() |
Requires AuthioMcpDefaults.Policy and authorizes each tools/call against its tool. |
CorsOptions.AddAuthioMcpPolicy(string name = AuthioMcpDefaults.CorsPolicy) |
CORS policy for browser MCP clients. |
AuthioMcpOptions.RequiredScopes |
Scopes every token must carry; none when left out. |
AuthioMcpDefaults.Policy / .CorsPolicy / .ConfigurationSection |
Names to refer to. |
From Authio.AspNetCore and Authio.Client: MapAuthioProtectedResourceMetadata(), GetAuthioUser(),
GetAuthioAccessToken(), GetAuthioResourceAccess(), the [Authio…] attributes and RequireAuthio… conventions,
IAuthioTokenClient.ExchangeForConnectionAsync, IAuthioIntrospectionClient. Every call to Authio goes through the
named HttpClient AuthioDefaults.HttpClientName ("Authio").
Upgrading
- From 0.3:
ClientAuthenticationis nowTokenEndpointAuthMethod(Authio.Client'sAuthioTokenEndpointAuthMethod) andValidAlgorithmsisAccessTokenSignedResponseAlg;RequiredScopesandIntrospectionare off when left out, so a server that relied onmcp:toolsandActiveChecksets them;AuthioMcpDefaults.Scopeis gone. See CHANGELOG.md.
Conformance
conformance/ holds the vectors every Authio library runs, copied from authio-node (see
conformance/SOURCE). The tests run callers.json, the access tokens of tokens.json (signed and JWE) and
resource-access.json through the MCP endpoint; conformance/PENDING lists what does not
apply here, with the reason.
License
| Product | Versions 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. |
-
net10.0
- Authio.AspNetCore (>= 0.9.0)
- ModelContextProtocol.AspNetCore (>= 2.2.0)
-
net8.0
- Authio.AspNetCore (>= 0.9.0)
- ModelContextProtocol.AspNetCore (>= 2.2.0)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.