C#
TL;DR — Two packages on NuGet.
Authplane.Mcpis the ASP.NET Core adapter: it registers middleware that verifies the bearer or DPoP-bound token, enforces a scope per request, and serves the RFC 9728 document before auth runs.Authplane.Sdkis the framework-agnostic core it sits on. Both target .NET 8 and .NET 10.
Install
dotnet add package Authplane.Mcp # ASP.NET Core + the official MCP C# SDK
dotnet add package Authplane.Sdk # core primitives only
Authplane.Mcp brings Authplane.Sdk along transitively, so an MCP server needs only the first. The core’s only dependency is System.IdentityModel.Tokens.Jwt.
Authplane.Mcp — ASP.NET Core adapter
Quickstart
using Authplane;
using Authplane.Mcp;
var builder = WebApplication.CreateBuilder(args);
var options = new AuthplaneMcpAuth.Options(
issuer: "https://auth.company.com",
resource: "https://mcp.company.com/mcp",
scopes: new[] { "tools/query", "tools/write" },
devMode: false);
builder.Services.AddSingleton<AuthplaneResource>(_ =>
AuthplaneMcpAuth.CreateResourceAsync(options).GetAwaiter().GetResult());
builder.Services.AddSingleton<IDPoPReplayStore, InMemoryDPoPReplayStore>();
builder.Services.AddMcpServer().WithHttpTransport().WithToolsFromAssembly();
var app = builder.Build();
app.UseAuthplaneMcpAuth(options);
app.MapMcp(pattern: "/mcp");
await app.RunAsync();
CreateResourceAsync performs RFC 8414 metadata discovery, so a server that reaches RunAsync already knows its authorization server; the JWKS itself is fetched lazily, on the first token verification. Registering IDPoPReplayStore is recommended even on a bearer-only deployment — it is where jti replay detection records proofs the moment a DPoP-bound token arrives. Without a registration the SDK falls back to a per-resource in-memory store, which is not shared across instances.
Scope per request, resolved from the request itself
The middleware enforces at most one scope per request, resolved in this order:
- an explicit
x-authplane-required-scopesheader on the inbound request; - otherwise, for a
tools/callrequest, the tool name from the JSON-RPC body, mapped by the fixedtools/{toolName}convention — and enforced only when that scope appears inOptions.scopes.
There is no configurable mapping and no fallback: a request that yields neither — a non-tools/call method with no header, or a derived tools/{name} that is not in Options.scopes — gets no per-request scope check. Token signature, expiry and audience are always verified regardless. For scope logic beyond the tools/{name} convention, enforce inside the tool handler with claims.RequireScope(...).
The PRM document is served for you
The middleware answers GET /.well-known/oauth-protected-resource{…} before auth runs, so discovery works for a client that has no token yet. That is the ordering RFC 9728 needs and the thing easiest to get wrong by hand.
If you need the JSON elsewhere:
var resource = serviceProvider.GetRequiredService<AuthplaneResource>();
var prmJson = resource.GetProtectedResourceMetadata().ToRfc9728Json();
Authplane.Sdk — core primitives
For a service that is not an MCP server, or not on ASP.NET Core.
using Authplane;
await using var client = await AuthplaneClient.CreateAsync(
issuer: "https://auth.example.com",
fetchSettings: FetchSettings.FromDevMode(devMode: false));
await using var resource = await client.CreateResourceAsync(
resource: "https://api.example.com/mcp",
scopes: new[] { "tools/echo" });
var claims = await resource.VerifyAsync(accessToken);
claims.RequireScope("tools/echo");
await using matters. Both types are IAsyncDisposable. The background metadata and JWKS refresh live on AuthplaneClient, and disposing it is what stops those tasks at shutdown; a resource built via client.CreateResourceAsync does not own the client, so dispose both — resource first, client last.
Verifying a DPoP-bound token
Pass a request context and the same token verifies as sender-constrained, with htm, htu, ath and replay all checked:
var requestContext = new DPoPRequestContext(
method: "POST",
url: "https://api.example.com/mcp",
proof: request.Headers["DPoP"].FirstOrDefault(),
replayStore: serviceProvider.GetRequiredService<IDPoPReplayStore>());
var claims = await resource.VerifyAsync(token, requestContext);
What you get back, and what is thrown
VerifyAsync returns VerifiedClaims or throws an AuthplaneException subclass — TokenMissingException, TokenExpiredException, InvalidSignatureException, InvalidClaimsException, InsufficientScopeException, JwksFetchException, MetadataFetchException. Catching the base class is enough to map everything to an RFC 6750 challenge; catch the specific ones when the response should differ.
Resource identifiers
The resource identifier is published verbatim to unauthenticated callers — it goes into the resource_metadata parameter of the 401 WWW-Authenticate challenge and into the served document’s resource field. Since 0.2.0 the SDK validates it at construction and throws ArgumentException, so an invalid value fails at startup rather than at the first 401:
- Absolute URL with a scheme and a host.
//api.example.com/mcpandurn:example:apiboth leave no derivable metadata URL. - No userinfo.
https://svc:pw@api.example.com/mcpwould publish a credential to every caller that asks. - No fragment. RFC 8707 §2 states the resource URI “MUST NOT include a fragment component”.
- No whitespace or backslash, anywhere in the string. This is the one an operator actually hits: a value read from a ConfigMap or an env var with a trailing newline.
- A valid query, against the RFC 3986 §3.4 grammar. Unescaped brackets (
?filter[a]=b) are the realistic failure — percent-encode them (?filter%5Ba%5D=b) to keep the identifier. - A well-formed port — in-range digits, no leading zero.
The path and host are checked against the RFC 3986 productions too: a non-ASCII segment such as /café or an internationalized host is rejected — percent-encode it.
Upgrading from 0.1.0
Identifiers fail at startup. Everything in the list above, which 0.1.0 accepted. The derived document URL is now the configured string verbatim with the well-known segment inserted, query included — an identifier with an uppercase host, a default port or dot-segments advertises a different URL than before.
error_description is a fixed sentence per error code, and Authplane.Mcp answers every failure with an RFC 6750 JSON body (error, error_description) instead of prose. Parse the body rather than matching text; AuthplaneErrors.WwwAuthenticate(...) takes verboseDescription: true for development. A JWKS or metadata outage now answers 503 temporarily_unavailable instead of 401 / server_error.
VerifiedClaims.MayAct is [Obsolete]; authserver 0.2.0 no longer issues may_act. It is removed in the next minor.
New in this release: Options.ResourceMetadataUrl for an AS-hosted PRM document, AccessDeniedException / InvalidTargetException for token exchange, and opt-in resource-server DPoP nonces (InboundDPoPOptions.nonceIssuer). The full list is in the 0.2.0 changelog.
Related
- SDKs overview — pick between adapters
- Quickstart — end-to-end in 10 minutes
- Guides: Enable DPoP end-to-end — configuring inbound DPoP correctly
- Guides: Wire up the Token Vault — token exchange for upstream vending
- Concepts: Resource servers and PRM — what the document is for
- Concepts: DPoP — why proof-of-possession, when to enable
- Full user guides in the cs-sdk repo: