Securing an MCP server
The MCP specification defines a standard OAuth 2.0 authorization flow that any compatible MCP client can follow to authenticate without any prior knowledge of the authorization server. Liberty supports this flow by using the openidConnectClient-1.0 feature for bearer-token validation and OAuth 2.0 Protected Resource Metadata (RFC 9728) for authorization server discovery.
Prerequisites
To use the standard MCP authorization flow, you need:
An OAuth 2.0 authorization server that publishes its own metadata at a well-known endpoint (RFC 8414). Most modern authorization servers support this.
The
openidConnectClient-1.0feature configured as a resource server on Liberty (see below).
Without the <protectedResourceMetadata> sub-element, token validation still works, but clients must be preconfigured with the authorization server details — they cannot discover it automatically.
How the authorization flow works
An MCP client makes an unauthenticated request to the MCP endpoint.
Liberty rejects the request with a
401response whoseWWW-Authenticateheader includes aresource_metadataURL pointing to Liberty’s discovery document:WWW-Authenticate: Bearer realm="oauth", resource_metadata="https://resource.example.com/.well-known/oauth-protected-resource/myapp/mcp"
The client fetches that URL and receives a JSON document identifying the authorization server.
The client fetches the authorization server’s own well-known metadata to discover its token and registration endpoints.
The client obtains a bearer token from the authorization server and retries the original request with an
Authorization: Bearer <token>header.Liberty validates the token and allows the request.
Any MCP client that implements the specification can complete this flow automatically without requiring the user to configure the authorization server details in the client.
Enable the features
Add mcp-1.0, openidConnectClient-1.0, and appSecurity-5.0 to your server.xml:
<featureManager>
<feature>servlet-6.0</feature>
<feature>cdi-4.0</feature>
<feature>mcp-1.0</feature>
<feature>openidConnectClient-1.0</feature>
<feature>appSecurity-5.0</feature>
</featureManager>Configure inbound token validation
To protect the MCP endpoint, configure <openidConnectClient> with inboundPropagation="required" and an <authFilter> that covers only the MCP endpoint path. This restricts bearer-token validation to the MCP endpoint path. All other paths, including /.well-known/oauth-protected-resource, remain unauthenticated so that clients can read the metadata document without a token.
<authFilter id="mcpAuthFilter">
<requestUrl id="mcpUrl" urlPattern="/myapp/mcp" matchType="contains"/>
</authFilter>
<openidConnectClient id="mcpResourceServer"
clientId="my-resource-server"
clientSecret="${env.OIDC_CLIENT_SECRET}"
issuerIdentifier="https://auth.example.com/realms/my-realm"
jwkEndpointUrl="https://auth.example.com/realms/my-realm/protocol/openid-connect/certs"
inboundPropagation="required"
authFilterRef="mcpAuthFilter">
</openidConnectClient>With inboundPropagation="required", Liberty returns a 401 for any request to the filtered path that does not carry a valid bearer token. Liberty does not redirect to a login page.
Publish OAuth 2.0 Protected Resource Metadata
To allow MCP clients to discover the authorization server automatically, add a <protectedResourceMetadata> sub-element to <openidConnectClient>:
<openidConnectClient id="mcpResourceServer"
clientId="my-resource-server"
clientSecret="${env.OIDC_CLIENT_SECRET}"
issuerIdentifier="https://auth.example.com/realms/my-realm"
jwkEndpointUrl="https://auth.example.com/realms/my-realm/protocol/openid-connect/certs"
inboundPropagation="required"
authFilterRef="mcpAuthFilter">
<protectedResourceMetadata
advertisedScopes="openid profile"/>
</openidConnectClient>Liberty serves a JSON document at /.well-known/oauth-protected-resource for any path covered by the authFilterRef. For example, a GET request to /.well-known/oauth-protected-resource/myapp/mcp returns:
{
"resource": "https://resource.example.com/myapp/mcp",
"authorization_servers": ["https://auth.example.com/realms/my-realm"],
"scopes_supported": ["openid", "profile"]
}The fields in the metadata document are populated as follows:
resource— derived from the URL of the incoming metadata request.authorization_servers— taken from theissuerIdentifierattribute on<openidConnectClient>. IfissuerIdentifieris not set, Liberty derives the value by removing the last path segment fromvalidationEndpointUrl. If neither is configured, this field is omitted.scopes_supported— populated from theadvertisedScopesattribute. Omitted whenadvertisedScopesis not set.
A GET request to a path not covered by any authFilterRef returns 404.
Add signed metadata
When jwtBuilderRef is set on <protectedResourceMetadata>, Liberty builds a compact JWS that contains the same fields as the plain metadata response and includes it as a signed_metadata field.
<openidConnectClient id="mcpResourceServer"
...
authFilterRef="mcpAuthFilter"
inboundPropagation="required">
<protectedResourceMetadata
advertisedScopes="openid profile"
jwtBuilderRef="myJwtBuilder"/>
</openidConnectClient>
<jwtBuilder id="myJwtBuilder" jwkEnabled="true" issuer="https://resource.example.com"/>The response also includes the signed_metadata field:
{
"resource": "https://resource.example.com/myapp/mcp",
"authorization_servers": ["https://auth.example.com/realms/my-realm"],
"scopes_supported": ["openid", "profile"],
"signed_metadata": "eyJhbGciOiJSUzI1NiJ9..."
}Complete server.xml example
<featureManager>
<feature>servlet-6.0</feature>
<feature>cdi-4.0</feature>
<feature>mcp-1.0</feature>
<feature>openidConnectClient-1.0</feature>
<feature>appSecurity-5.0</feature>
</featureManager>
<!-- Only requests to the MCP endpoint require a bearer token -->
<authFilter id="mcpAuthFilter">
<requestUrl id="mcpUrl" urlPattern="/myapp/mcp" matchType="contains"/>
</authFilter>
<openidConnectClient id="mcpResourceServer"
clientId="my-resource-server"
clientSecret="${env.OIDC_CLIENT_SECRET}"
issuerIdentifier="https://auth.example.com/realms/my-realm"
jwkEndpointUrl="https://auth.example.com/realms/my-realm/protocol/openid-connect/certs"
inboundPropagation="required"
authFilterRef="mcpAuthFilter">
<protectedResourceMetadata
advertisedScopes="openid profile"/>
</openidConnectClient>
<keyStore id="defaultKeyStore" password="Liberty"/>
<ltpa keysFileName="ltpa.keys" keysPassword="Liberty" expiration="120"/>
<webApplication contextRoot="/myapp" location="myapp.war">
<mcp path="/mcp"/>
</webApplication>Controlling access per tool
Once a bearer token is validated, you can restrict which roles can access individual tools by using standard Jakarta Security annotations on your tool methods or classes. Liberty enforces these annotations on every tools/call request. Liberty also filters tools that the authenticated user is not permitted to call from tools/list responses.
For the application to recognise roles, declare them using <security-role> elements in WEB-INF/web.xml.
The following annotations are supported:
| Annotation | Effect |
|---|---|
| Only callers whose token includes the named role are permitted. |
| All callers are permitted. |
| No caller is ever permitted. Always returns |
Annotations can be placed on the method or on the class. Method-level annotations take precedence over class-level annotations.
import jakarta.annotation.security.RolesAllowed;
import jakarta.enterprise.context.ApplicationScoped;
import org.mcpjava.server.tools.Tool;
import org.mcpjava.server.tools.ToolArg;
@ApplicationScoped
public class OrderTools {
// Any user can call this tool
@Tool(name = "listOrders", description = "List orders.")
public String listOrders() {
return orderService.getAll();
}
// Only users with the 'admin' role can call this tool
@Tool(name = "deleteOrder", description = "Delete an order by ID.")
@RolesAllowed("admin")
public String deleteOrder(@ToolArg(name = "id", description = "Order ID") String id) {
orderService.delete(id);
return "Deleted order " + id;
}
}To map token group claims to Liberty roles, set the groupIdentifier attribute on <openidConnectClient> to the name of the bearer token claim that contains the user’s group memberships:
<openidConnectClient id="mcpResourceServer"
...
groupIdentifier="groups"
inboundPropagation="required"
authFilterRef="mcpAuthFilter">
</openidConnectClient>When a token contains "groups": ["admin", "user"], the caller can access a tool annotated with @RolesAllowed("admin"). If the caller is authenticated but does not have the required role, Liberty returns 403 Forbidden. If the caller is unauthenticated, Liberty returns 401 Unauthorized.
Restrict access at the endpoint level
You can also combine <security-role> with a <security-constraint> to enforce endpoint-level access control, so that role checks are applied at the endpoint before any per-tool checks.
<web-app xmlns="https://jakarta.ee/xml/ns/jakartaee" version="6.0">
<security-role>
<role-name>user</role-name>
</security-role>
<security-role>
<role-name>admin</role-name>
</security-role>
<security-constraint>
<web-resource-collection>
<web-resource-name>MCP endpoint</web-resource-name>
<url-pattern>/mcp</url-pattern>
<url-pattern>/mcp/*</url-pattern>
</web-resource-collection>
<auth-constraint>
<role-name>user</role-name>
<role-name>admin</role-name>
</auth-constraint>
</security-constraint>
</web-app>With this configuration, only users with the user or admin role can reach the MCP endpoint. Individual tools can then further restrict access — for example, a tool annotated with @RolesAllowed("admin") can be called only by users with the admin role, even though users with the user role can reach the endpoint.
Troubleshooting
| Symptom | What to check |
|---|---|
Metadata endpoint returns | Confirm that |
| Confirm that the |
| Set |
Requests with a token still receive | Confirm that the |