C#

TL;DR — Two packages on NuGet. Authplane.Mcp is 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.Sdk is 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:

  1. an explicit x-authplane-required-scopes header on the inbound request;
  2. otherwise, for a tools/call request, the tool name from the JSON-RPC body, mapped by the fixed tools/{toolName} convention — and enforced only when that scope appears in Options.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.

TypeRole
AuthplaneClientIssuer-scoped infrastructure — discovery, shared HttpClient, JWKS cache, circuit breaker
AuthplaneResourcePer-resource verifier; owns the aud URI and required scopes
AuthplaneAuthClientOAuth client operations — client credentials, introspection, token exchange, revocation, optional outbound DPoP signer
VerifiedClaimsImmutable claim set; RequireScope for enforcement
ProtectedResourceMetadataRFC 9728 payload, with ToRfc9728Json() for the wire format

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/mcp and urn:example:api both leave no derivable metadata URL.
  • No userinfo. https://svc:pw@api.example.com/mcp would 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.