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.

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/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.

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.