Soklet Logo

Model Context Protocol

MCP OAuth Integration

An OAuth-protected MCP server has three roles: the MCP host acts as the OAuth client, your Soklet application acts as the protected resource, and an authorization server issues tokens. The authorization server may be a separate identity provider. Soklet does not issue tokens or send users to a login page.

When a credential is missing or invalid, the application returns HTTP 401 with a WWW-Authenticate Bearer challenge pointing to protected resource metadata. That document names the authorization server; the host discovers its login and token endpoints, obtains a token, and retries the MCP request. A valid token missing a grantable operation scope can receive HTTP 403 with error="insufficient_scope" and the required scope. A permanent application policy denial remains a 403 without an OAuth scope challenge. The MCP authorization specification defines the discovery and challenge flow.

This is an application-owned pattern. OAuth is optional for MCP and is not required merely because an MCP tool has an App interface.

Application-owned OAuth resource-server pattern

Soklet 4.0.0 provides the point at which an application admits and identifies each MCP request. The application remains the OAuth resource server: it chooses an authorization server, verifies access tokens, maps claims to scopes, serves protected resource metadata, and owns revocation and provider policy.

Soklet does not implement an OAuth authorization server, token endpoint, JWT/JWK client, introspection client, dynamic client registration, consent flow, protected resource metadata hosting, or identity-provider-specific policy. The example below supplies those application seams without pretending they are framework features.

Boundary and flow

For every structurally valid MCP message:

  1. Soklet invokes McpAdmissionController before rate limiting, framework discovery/catalog rendering, interception, typed conversion, or a handler.
  2. A trusted edge rejects duplicate physical Authorization fields. The application then extracts exactly one materialized Bearer credential value and asks its token verifier to authenticate it.
  3. The application checks the scope for the validated MCP method and selected operation.
  4. A rejection returns a safe HTTP status, JSON-RPC error, and optional WWW-Authenticate challenge. A notification still carries the HTTP status and headers but has no JSON-RPC response body.
  5. An acceptance carries stable opaque partition keys and an application principal into McpRequestContext.getAdmissionIdentity().

Connection reuse does not reuse identity. Every request and notification is admitted independently.

Token-verification seam

This interface is intentionally application code. Its implementation might use a locally verified JWT, RFC 7662 introspection, a sidecar, or a gateway-attested credential, according to the deployment's trust model.

package example.security;

import java.time.Instant;
import java.util.Optional;
import java.util.Set;

public interface AccessTokenVerifier {
  Optional<TokenClaims> verify(String encodedToken);

  record TokenClaims(
      String issuer,
      String subject,
      String tenant,
      Set<String> audiences,
      Set<String> scopes,
      Instant expiresAt) {
    public boolean hasScope(String scope) {
      return scopes.contains(scope);
    }
  }
}

Before returning claims, a production implementation must at least validate the token's integrity/authenticity, allowed issuer, resource audience, expiry and not-before constraints, algorithm/key policy, and deployment-specific revocation semantics. Do not decode a JWT and treat its unverified payload as claims. Do not log or place the encoded token in an exception message.

Partition keys should also be application-derived, stable, bounded, and opaque:

package example.security;

@FunctionalInterface
public interface PartitionKeyDeriver {
  // For example: versioned HMAC over issuer + tenant + subject.
  String derive(AccessTokenVerifier.TokenClaims claims);
}

Do not use a raw access token as either partition key.

Admission and authorization controller

The following controller is complete apart from the two application seams above. Its fixed challenge vocabulary comes only from application-defined scope constants and a fixed registration-name-to-scope map; it contains no raw request value, token, subject, tenant, or provider error text.

package example.security;

import com.soklet.BearerAuthenticationChallenge;
import com.soklet.BearerAuthenticationError;
import com.soklet.McpAdmissionContext;
import com.soklet.McpAdmissionController;
import com.soklet.McpAdmissionDecision;
import com.soklet.McpAdmissionIdentity;
import com.soklet.McpAdmissionRejection;
import com.soklet.McpJsonRpcError;
import com.soklet.Request;

import java.net.URI;
import java.util.List;
import java.util.Map;
import java.util.Optional;

public final class OAuthAdmissionController
    implements McpAdmissionController {
  private static final URI RESOURCE_METADATA_URI = URI.create(
      "https://api.example.com/.well-known/"
          + "oauth-protected-resource/catalog/mcp");
  private static final Map<String, String> TOOL_SCOPES = Map.of(
      "catalog.search", "mcp:tools:call:catalog.search");

  private final AccessTokenVerifier tokenVerifier;
  private final PartitionKeyDeriver partitionKeyDeriver;

  public OAuthAdmissionController(
      AccessTokenVerifier tokenVerifier,
      PartitionKeyDeriver partitionKeyDeriver) {
    this.tokenVerifier = tokenVerifier;
    this.partitionKeyDeriver = partitionKeyDeriver;
  }

  @Override
  public McpAdmissionDecision admit(McpAdmissionContext context) {
    Optional<String> requiredScope = requiredScope(context);
    if (requiredScope.isEmpty()) {
      return unmappedOperation();
    }
    String scope = requiredScope.orElseThrow();
    Optional<String> credential = bearerCredential(context.getRequest());
    if (credential.isEmpty()) {
      return unauthorized(scope, false);
    }

    Optional<AccessTokenVerifier.TokenClaims> verified =
        tokenVerifier.verify(credential.orElseThrow());
    if (verified.isEmpty()) {
      return unauthorized(scope, true);
    }

    AccessTokenVerifier.TokenClaims claims = verified.orElseThrow();
    if (!claims.hasScope(scope)) {
      return forbidden(scope);
    }

    String partitionKey = partitionKeyDeriver.derive(claims);
    McpAdmissionIdentity identity =
        McpAdmissionIdentity.withRateLimitPartitionKey(partitionKey)
            .authorizationPartitionKey(partitionKey)
            .principal(claims)
            .build();
    return McpAdmissionDecision.accepted(identity);
  }

  private static Optional<String> requiredScope(
      McpAdmissionContext context) {
    return switch (context.getOperationType()) {
      case SERVER_DISCOVER, TOOLS_LIST, PROMPTS_LIST,
          RESOURCES_LIST, RESOURCES_TEMPLATES_LIST ->
          Optional.of("mcp:discover");
      case TOOLS_CALL -> context.getOperationName()
          .flatMap(name -> Optional.ofNullable(TOOL_SCOPES.get(name)));
      case PROMPTS_GET -> Optional.of("mcp:prompts:get");
      case RESOURCES_READ -> Optional.of("mcp:resources:read");
      case SUBSCRIPTIONS_LISTEN ->
          Optional.of("mcp:subscriptions:listen");
      default -> Optional.empty();
    };
  }

  private static Optional<String> bearerCredential(Request request) {
    List<String> values = request.getHeaders().entrySet().stream()
        .filter(entry -> entry.getKey().equalsIgnoreCase("Authorization"))
        .flatMap(entry -> entry.getValue().stream())
        .toList();
    if (values.size() != 1) {
      return Optional.empty();
    }

    String value = values.get(0);
    if (value.length() <= 7
        || !value.regionMatches(true, 0, "Bearer ", 0, 7)) {
      return Optional.empty();
    }
    String credential = value.substring(7);
    if (credential.chars().anyMatch(Character::isWhitespace)) {
      return Optional.empty();
    }
    return Optional.of(credential);
  }

  private static McpAdmissionDecision unauthorized(
      String scope, boolean invalidToken) {
    BearerAuthenticationChallenge.Builder challenge =
        BearerAuthenticationChallenge
            .withResourceMetadataUri(RESOURCE_METADATA_URI)
            .requiredScopes(List.of(scope));
    if (invalidToken) {
      challenge.error(BearerAuthenticationError.INVALID_TOKEN);
    }
    return McpAdmissionDecision.rejected(
        McpAdmissionRejection.withBearerAuthenticationChallengeAndError(
                challenge.build(),
                McpJsonRpcError.fromApplication(
                    -31901, "Authentication required"))
            .build());
  }

  private static McpAdmissionDecision forbidden(String scope) {
    BearerAuthenticationChallenge challenge =
        BearerAuthenticationChallenge
            .withResourceMetadataUri(RESOURCE_METADATA_URI)
            .error(BearerAuthenticationError.INSUFFICIENT_SCOPE)
            .requiredScopes(List.of(scope))
            .build();
    return McpAdmissionDecision.rejected(
        McpAdmissionRejection.withBearerAuthenticationChallengeAndError(
                challenge,
                McpJsonRpcError.fromApplication(
                    -31903, "Operation not permitted"))
            .build());
  }

  private static McpAdmissionDecision unmappedOperation() {
    return McpAdmissionDecision.rejected(
        McpAdmissionRejection.withStatusCodeAndError(
                403,
                McpJsonRpcError.fromApplication(
                    -31903, "Operation not permitted"))
            .build());
  }
}

Soklet's request model preserves every header occurrence in an ordered list, including identical repeated values. Header names are normalized case insensitively. This verifier rejects more than one Authorization value, including identical duplicates. Configure the trusted edge to preserve these occurrences or reject duplicates before forwarding. A proxy that coalesces or removes fields can hide their original physical form from the application.

McpAdmissionController can be invoked concurrently; the verifier and partition-key derivation must therefore be thread-safe. Bound their network, cache, and cryptographic work so admission cannot become an unbounded resource sink. If the controller throws or returns malformed data, Soklet fails closed.

The scope mapping is an example policy, not a Soklet-defined vocabulary. In particular, URI-template resources and tenant-specific records usually need authorization beyond the coarse resources/read scope. Perform that semantic check in the selected handler against the accepted principal, and return one fixed no-data error for unknown, missing, and unauthorized protected values.

Install the controller

Install the controller on a fresh builder carrying your shared MCP server configuration:

AccessTokenVerifier verifier = applicationTokenVerifier();
PartitionKeyDeriver partitionKeys = applicationPartitionKeyDeriver();

McpServer mcpServer = mcpServerBuilder
    .admissionController(new OAuthAdmissionController(verifier, partitionKeys))
    .host("127.0.0.1")
    .corsAuthorizer(CorsAuthorizer.rejectAllInstance())
    .allowedHosts(Set.of("api.example.com"))
    .build();

The HTTPS gateway must preserve the public Host api.example.com; the listener may bind loopback while authorizing that public authority. Configure the allowlist for the actual public hostname.

Protected resource metadata

Serve metadata from application-owned ordinary HTTP infrastructure or a gateway, not from the dedicated MCP listener. For example, an HTTPS response at https://api.example.com/.well-known/oauth-protected-resource/catalog/mcp might be:

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=300

{
  "resource": "https://api.example.com/catalog/mcp",
  "authorization_servers": ["https://id.example.com"],
  "scopes_supported": [
    "mcp:discover",
    "mcp:tools:call:catalog.search",
    "mcp:prompts:get",
    "mcp:resources:read",
    "mcp:subscriptions:listen"
  ]
}

The application or gateway owns this response, its standards compliance, and its synchronization with the actual audience, issuer, and scope policy. The MCP listener serves configured protocol transports, including optional 2025 session GET/DELETE, and does not become a general OAuth metadata server.

Expected failures

ConditionHTTPJSON-RPC bodyRequired behavior
Missing or malformed Bearer credential401Fixed application error for requests; empty for notificationsInclude a fixed Bearer challenge and metadata URI; do not reflect input.
Token fails application verification401Same fixed request errorinvalid_token may be named in the challenge; do not include provider details.
Authenticated identity lacks the operation scope403Fixed no-data authorization error for requestsThe handler and downstream services must not run.
Authenticated identity is permanently denied by application policy403Fixed no-data authorization error for requestsDo not send an insufficient_scope challenge when no added scope could grant access.
Admission verifier throws/times outFramework failure responseSafe framework errorFail closed; observe the internal failure without exposing credentials.
Origin or Host is rejected403 or transport rejectionNo application admission resultCORS/Host checks remain separate from OAuth.
Handler-level protected object is unknown or unauthorizedApplication-defined safe errorSame response shape for bothDo not reveal existence, owner, path, or policy details.

Soklet validates application-supplied response headers and owns JSON-RPC serialization. BearerAuthenticationChallenge validates and renders the challenge syntax; the application remains responsible for token verification, scope policy, and publishing the matching protected-resource metadata. For browser clients, explicitly allow the intended Origin and expose WWW-Authenticate only as required. For every remote deployment, terminate TLS at a trusted boundary and ensure proxies do not accept or synthesize identity headers outside that boundary.

Minimum security tests

  • missing, multiple authorization occurrences, including identical duplicates, wrong-scheme, mixed-case valid Bearer scheme, extra-whitespace, blank, malformed, expired, wrong-issuer, wrong-audience, and bad-signature credentials all behave as intended and otherwise fail closed;
  • the trusted edge rejects duplicate physical Authorization fields, including identical values and mixed-case header-name duplicates;
  • no response, log, metric, exception, trace, or partition key contains the encoded token or sensitive claim;
  • discovery/list authorization is intentional rather than assumed public;
  • operation-specific scopes cannot authorize a different tool or tenant, and every registered tool missing from the explicit scope map is rejected;
  • notifications receive no JSON-RPC response body while preserving the safe HTTP status and challenge behavior;
  • rejected Origin and Host values do not reach the token verifier;
  • verifier timeout/circuit-breaker/cache behavior is bounded; and
  • token/key rotation and revocation behavior is tested according to the chosen provider and deployment.
Previous
Security and Policy