Skip to content

What a server receives and must verify

Your server never signs users in. The gateway does, then calls your server with its own short-lived token and, for the target system, the user’s credential. If you use the starter (mcp-platform-starter), it makes every check below for you.

AI clients use OAuth with the platform’s identity provider (Keycloak). The gateway is a protected resource as RFC 9728 defines it:

What Where
Metadata of the /mcp endpoint https://<platform>/.well-known/oauth-protected-resource/mcp
Metadata of one server’s route https://<platform>/.well-known/oauth-protected-resource/s/mcp-<system>/mcp
Public keys the servers use to verify the gateway https://<platform>/.well-known/mcp-jwks.json

A request without a valid token gets 401 with WWW-Authenticate: Bearer resource_metadata="..." pointing at that metadata. The client follows it and starts the login. A client that supports RFC 9728 needs no other configuration. If the identity provider’s key set is unavailable, the gateway answers 503 with Retry-After and no WWW-Authenticate, so the client does not start a login that would not help.

The gateway signs a new token for every request to a server (ES256). The server verifies it against the key set at MCP_GATEWAY_JWKS_URI.

Claim Meaning
iss The gateway issuer, by default https://mcp-gateway.internal
aud The id of the receiving server, mcp-<system>. One string, never a list
sub The user’s sub from the identity provider; system:mcp-gateway in the service variant
usr The user’s preferred_username
grp The user’s groups with the mcp- prefix
azp The OAuth client id of the AI client
tool The server-side tool name. Present exactly for tools/call and equal to params.name
use Purpose: call for the MCP endpoint, policy-data for the policy push
rid Request id, must equal the X-Request-Id header
prv Revision of the gateway’s policy data
mfa Seconds since the last multi-factor login, when known
iat, exp, jti Issue time, expiry and a unique id

The server must refuse the request with 401 unless all of these hold:

  • the signature is valid, alg is ES256 and the kid is in the key set (a kid starting with test- or dev- only in dev mode);
  • aud is the server’s own id;
  • exp - iat is at most 60 seconds, and exp and iat are within 30 seconds of the server’s clock;
  • the jti was not seen before (every token is single-use; memory by default, Redis when several instances run);
  • rid equals X-Request-Id;
  • use is the purpose of the endpoint: a policy-data token at tools/call is refused as wrong_purpose, and the other way round;
  • tool is present for tools/call and absent for every other request;
  • the service variant (no user, no tool) appears only for initialize, server/discover, tools/list, ping and the policy push.

GET /.well-known/mcp-manifest takes no token and /actuator/health is the only other open path. The server never reads the Host header and asks for no client certificate.

The vectors in mcp-contracts (test-vectors/jwt/) give the expected result for each rule, see Contracts.

InternalJwtFilter runs all the checks above before any tool code. A rejection is 401 with WWW-Authenticate: Bearer; the reason goes to the log (event=internal_jwt_rejected reason=<code>) and the audit, not to the caller. An unavailable key set or Redis gives 503, never a pass. You set only the key set address and the server id:

aiwcorpo.mcp.platform:
server-id: mcp-notes
internal-jwt:
jwks-uri: ${MCP_GATEWAY_JWKS_URI}

Inside a tool, CallerContext.current() returns the verified subject, username, groups, client, tool, requestId, policyRevision and mfaAgeSeconds. The server decides nothing about who may call a tool; the gateway does. The server checks the resources the tool names, see Write a server from the template.

The gateway gets the user’s credential from the credential broker and sends it with each call in two headers: X-MCP-Credential (the value) and X-MCP-Credential-Type. The type is one of user-oauth, user-secret, token-exchange and service-account. The type must be one of the server’s accepted-credential-types; otherwise the server answers internal_error before it touches policy or the system.

BackendCredential credential = CredentialContext.require("notes");
restClient.get()
.uri("/api/records/{id}", id)
.headers(credential::applyTo)
.retrieve()
.body(Record.class);

applyTo sets Authorization: <scheme> <value>. CredentialContext.require throws when no credential came with the request, and the starter turns that into credential_required with the portal link from MCP_CONNECT_URL. After reading, the starter hides Authorization, X-MCP-Credential and X-PAT-* from the rest of the request, and removes the value from every message and log line. The server never stores a credential.