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.
On the published 0.1.0 packages the identifier is not validated beyond being non-empty, so a malformed value surfaces late — as a 500 out of the 401 path, or as a discovery URL that conformant clients silently discard. Keep it clean yourself:
- 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.
Changes in 0.2.0 (upcoming, not yet on NuGet) — the SDK will enforce all of the above at construction, with eight gates: absolute URL with scheme and host, RFC 3986 §3.2.2 host production, §3.3 path production, no userinfo, no fragment, no whitespace or backslash, a valid §3.4 query, and a well-formed port. An invalid identifier will then fail at startup rather than at the first 401.
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: