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 client to gateway
Section titled “AI client to gateway”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.
Gateway to server: the internal JWT (C2)
Section titled “Gateway to server: the internal JWT (C2)”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,
algis ES256 and thekidis in the key set (akidstarting withtest-ordev-only in dev mode); audis the server’s own id;exp - iatis at most 60 seconds, andexpandiatare within 30 seconds of the server’s clock;- the
jtiwas not seen before (every token is single-use; memory by default, Redis when several instances run); ridequalsX-Request-Id;useis the purpose of the endpoint: apolicy-datatoken attools/callis refused aswrong_purpose, and the other way round;toolis present fortools/calland absent for every other request;- the service variant (no user, no
tool) appears only forinitialize,server/discover,tools/list,pingand 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.
What the starter does
Section titled “What the starter does”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 credential for the target system (C3)
Section titled “The credential for the target system (C3)”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.
