This post continues from my previous article on the Model Context Protocol (MCP), where we built MCP servers for an Identity System that enable support users to query and modify identity data. In this article, the focus shifts to securing MCP servers — establishing trust across components, enabling protected resource discovery, and safeguarding real-time communication. All concepts are demonstrated using the Ping Identity suite, showing how a PingGateway-driven platform can act as a central enforcement point while allowing MCP servers to remain focused on core functionality.
The MCP specification (version 2025-06-18) adopts OAuth2-based authorization, standardizing discovery, client registration, and consent to enable automated trust. Its strength is amplified by extensions like RFC 8414, RFC 9728, and RFC 7591, which remove friction and allow secure interoperability with minimal manual effort.
In this post, I’ll explore these concepts through the Gateway Pattern. Using a gateway in front of MCP servers centralizes cross-cutting security concerns — allowing MCP servers to remain lean and focus on core functionality.
Architecture
The architecture comprises multiple components, each fulfilling a specific role in facilitating secure, standards-based access to identity data through the MCP server.
- MCP Inspector – This acts as a client that implements the latest MCP specification. It interacts with MCP servers via PingGateway to query or update identity data.
- PingGateway – Serving as a gateway, this component provides OAuth2 extensions and functions as a Resource Server (RS). It enforces coarse-grained authorization via scopes, handles incoming client requests, and ensures that only authorized calls reach the MCP servers.
- PingPlatform – PingPlatform plays a dual role in the architecture. It acts as the Authorization Server (AS), delivering OAuth2 functionality to enforce the MCP authorization specification, and simultaneously serves as a data platform, hosting identity data that MCP servers make accessible to Hosts/Clients.
- MCP IReader – A read-only MCP server component that exposes identity data for consumption by clients like MCP Inspector or Claude.
- MCP IWriter – A write-only MCP server component responsible for accepting updates to identity data.
NOTE: While a single MCP server could manage both reading and writing identity data, this demo separates responsibilities across two servers to fulfill our requirement for multiple MCP servers and show how a single gateway can centrally enforce security.

Extend PingGateway with RFC9728
By default, PingGateway does not support discovery or implement RFC 9728 (Protected Resource Metadata). To enable clients like MCP Inspector to automatically discover protected resources, PingGateway must be extended to expose resource metadata endpoints. These endpoints provide details about available MCP servers, supported scopes, and token requirements, allowing any compliant client to dynamically retrieve the information needed to interact securely with the underlying MCP servers.
Onboarding each MCP server in this POC involves two key steps: registering a route for its protected resource metadata and registering a second route to provide access to MCP server endpoints, such as SSE and Message.
Since there are two MCP servers, the platform includes two metadata routes along with their corresponding MCP server proxy routes. The example below highlights one of the servers, the IReader MCP Server. Currently, the metadata is sourced from a properties file, which is not ideal—RFC 9728 expects metadata to be dynamically updatable, and a static file limits that flexibility. Nonetheless, for this proof-of-concept, a properties file is sufficient.
{
"name": "RFC9728 IReader Metadata Route",
"baseURI": "http://forgeops.sqoopid.local:9090",
"condition": "${matchesWithRegex(request.uri.path, '^/.well-known/oauth-protected-resource/sse$')}",
"handler": {
"type": "ScriptableHandler",
"config": {
"type": "application/x-groovy",
"file": "specifications/rfc9728-ireader-mcp-metadata.groovy"
}
}
}
// rfc9728-ireader-mcp-metadata.groovy
import org.forgerock.http.protocol.Response
def resourceMetadata = pgConfig.resourceMetadatas.ireaderMetadata
def response = new Response(Status.OK)
response.headers['Content-Type'] = 'application/json'
response.entity = resourceMetadata
return response
// Properties file
{
"resourceMetadatas": {
"ireaderMetadata": {
"resource": "http://forgeops.sqoopid.local:9090/sse",
"authorization_servers": [
"https://forgeops.sqoopid.local/am/oauth2"
],
"bearer_methods_supported": [
"header",
"body"
],
"scopes_supported": [
"profile"
]
},
"iwriterMetadata": {
"resource": "http://forgeops.sqoopid.local:9090/iwriter-sse",
"authorization_servers": [
"https://forgeops.sqoopid.local/am/oauth2"
],
"bearer_methods_supported": [
"header"
],
"scopes_supported": [
"profile"
]
}
}
}
Proxying Server-Side Events (SSE) via PingGateway
To reliably proxy Server-Sent Events (SSE), I set a generously large timeout for the soTimeout property in the ReverseProxyHandler, which has worked effectively in practice.
"handler": {
"type": "ReverseProxyHandler",
"config": {
"soTimeout": "1 hours",
"propagateDisconnection": true,
"tls": "ClientTlsOptions-1"
}
}
On-board MCP Servers via Gateway Pattern
As highlighted above, onboarding an MCP server into PingGateway involves creating a dedicated route for that server. Each route acts as the secure entry point for requests coming from clients like MCP Inspector and ensures that the traffic is governed by OAuth2 Resource Server (RS) capabilities provided by the gateway.
By registering the route, PingGateway enforces token validation, and scope checks before forwarding calls to the MCP server. This centralizes security at the gateway and keeps the MCP server lightweight, focusing only on its core functionality.
In addition to standard OAuth2 RS functionality, MCP servers often need to make downstream API calls—for example, when reading identity data from IDM (part of Ping Platform) in this POC. To achieve this securely, PingGateway is configured with OAuth2 Token Exchange (RFC 8693). Token Exchange allows Ping Platform to issue a new, restricted-scoped token on behalf of the client, ensuring that downstream calls comply with the principle of least privilege.
By combining per-MCP server routes with advanced token handling, PingGateway not only secures direct client access but also enables safe and compliant downstream interactions, forming a robust trust fabric across the MCP ecosystem.
Below, you can see routes proxying both MCP servers — the IReader MCP Server and the IWriter MCP Server.
// MCP Server - IReader
{
"name": "PIFR MCP IReader",
"baseURI": "http://forgeops.sqoopid.local:9050",
"condition": "${matchesWithRegex(request.uri.path, '^/sse$') || matchesWithRegex(request.uri.path, '^/mcp/message.*$')}",
"heap": [
{
"name": "SystemAndEnvSecretStore-1",
"type": "SystemAndEnvSecretStore"
},
{
"name": "ClientTlsOptions-1",
"type": "ClientTlsOptions",
"config": {
"trustManager": {
"type": "TrustAllManager"
},
"hostnameVerifier": "ALLOW_ALL"
}
},
{
"name": "AmHandler-1",
"type": "ClientHandler",
"config": {
"soTimeout": "5 hours",
"propagateDisconnection": true,
"tls": "ClientTlsOptions-1"
}
},
{
"name": "AmService-1",
"type": "AmService",
"config": {
"agent": {
"username": "ig-gateway-rs",
"passwordSecretId": "agent.secret.id"
},
"amHandler": {
"type": "Chain",
"config": {
"handler": "AmHandler-1",
"filters": [
"TransactionIdOutboundFilter"
]
}
},
"notifications": {
"enabled": true,
"tls": "ClientTlsOptions-1",
"initialConnectionAttempts": 3,
"reconnectDelay": "5s",
"heartbeatInterval": "10s",
"connectionTimeout": "5s",
"idleTimeout": "30s"
},
"secretsProvider": "SystemAndEnvSecretStore-1",
"url": "https://forgeops.sqoopid.local/am/"
}
},
{
"name": "ExchangeHandler",
"type": "Chain",
"config": {
"filters": [
{
"type": "ClientSecretBasicAuthenticationFilter",
"config": {
"clientId": "serviceConfidentialClient",
"clientSecretId": "client.secret.id",
"secretsProvider": "SystemAndEnvSecretStore-1"
}
}
],
"handler": "AmHandler-1"
}
},
{
"name": "ExchangeFailureHandler",
"type": "StaticResponseHandler",
"config": {
"status": 400,
"entity": "${contexts.oauth2Failure.error}: ${contexts.oauth2Failure.description}",
"headers": {
"Content-Type": [
"application/json"
]
}
}
}
],
"handler": {
"type": "Chain",
"config": {
"filters": [
{
"name": "OAuth2ResourceServerFilter-1",
"type": "OAuth2ResourceServerFilter",
"config": {
"scopes": [
"profile"
],
"requireHttps": false,
"accessTokenResolver": {
"name": "TokenIntrospectionAccessTokenResolver-1",
"type": "TokenIntrospectionAccessTokenResolver",
"config": {
"amService": "AmService-1",
"providerHandler": {
"type": "Chain",
"config": {
"filters": [
{
"type": "HttpBasicAuthenticationClientFilter",
"config": {
"username": "ig-gateway-rs",
"passwordSecretId": "agent.secret.id",
"secretsProvider": "SystemAndEnvSecretStore-1"
}
}
],
"handler": "AmHandler-1"
}
}
}
}
}
},
{
"name": "oauth2TokenExchangeFilter",
"type": "OAuth2TokenExchangeFilter",
"config": {
"amService": "AmService-1",
"endpointHandler": "ExchangeHandler",
"subjectToken": "#{attributes.get('bearerToken')}",
"scopes": [
"fr:idm:*"
]
}
}
],
"handler": {
"type": "ReverseProxyHandler",
"config": {
"soTimeout": "5 hours",
"propagateDisconnection": true,
"tls": "ClientTlsOptions-1"
}
}
}
}
}
// MCP Server - IWriter
{
"name": "PIFR MCP IWriter",
"baseURI": "http://forgeops.sqoopid.local:9060",
"condition": "${matchesWithRegex(request.uri.path, '^/iwriter-sse$') || matchesWithRegex(request.uri.path, '^/mcp/iwriter/message.*$')}",
"heap": [
{
"name": "SystemAndEnvSecretStore-1",
"type": "SystemAndEnvSecretStore"
},
{
"name": "ClientTlsOptions-1",
"type": "ClientTlsOptions",
"config": {
"trustManager": {
"type": "TrustAllManager"
},
"hostnameVerifier": "ALLOW_ALL"
}
},
{
"name": "AmHandler-1",
"type": "ClientHandler",
"config": {
"soTimeout": "5 hours",
"propagateDisconnection": true,
"tls": "ClientTlsOptions-1"
}
},
{
"name": "AmService-1",
"type": "AmService",
"config": {
"agent": {
"username": "ig-gateway-rs",
"passwordSecretId": "agent.secret.id"
},
"amHandler": {
"type": "Chain",
"config": {
"handler": "AmHandler-1",
"filters": [
"TransactionIdOutboundFilter"
]
}
},
"notifications": {
"enabled": true,
"tls": "ClientTlsOptions-1",
"initialConnectionAttempts": 3,
"reconnectDelay": "5s",
"heartbeatInterval": "10s",
"connectionTimeout": "5s",
"idleTimeout": "30s"
},
"secretsProvider": "SystemAndEnvSecretStore-1",
"url": "https://forgeops.sqoopid.local/am/"
}
},
{
"name": "ExchangeHandler",
"type": "Chain",
"config": {
"filters": [
{
"type": "ClientSecretBasicAuthenticationFilter",
"config": {
"clientId": "serviceConfidentialClient",
"clientSecretId": "client.secret.id",
"secretsProvider": "SystemAndEnvSecretStore-1"
}
}
],
"handler": "AmHandler-1"
}
},
{
"name": "ExchangeFailureHandler",
"type": "StaticResponseHandler",
"config": {
"status": 400,
"entity": "${contexts.oauth2Failure.error}: ${contexts.oauth2Failure.description}",
"headers": {
"Content-Type": [
"application/json"
]
}
}
}
],
"handler": {
"type": "Chain",
"config": {
"filters": [
{
"name": "OAuth2ResourceServerFilter-1",
"type": "OAuth2ResourceServerFilter",
"config": {
"scopes": [
"profile"
],
"requireHttps": false,
"accessTokenResolver": {
"name": "TokenIntrospectionAccessTokenResolver-1",
"type": "TokenIntrospectionAccessTokenResolver",
"config": {
"amService": "AmService-1",
"providerHandler": {
"type": "Chain",
"config": {
"filters": [
{
"type": "HttpBasicAuthenticationClientFilter",
"config": {
"username": "ig-gateway-rs",
"passwordSecretId": "agent.secret.id",
"secretsProvider": "SystemAndEnvSecretStore-1"
}
}
],
"handler": "AmHandler-1"
}
}
}
}
}
},
{
"name": "oauth2TokenExchangeFilter",
"type": "OAuth2TokenExchangeFilter",
"config": {
"amService": "AmService-1",
"endpointHandler": "ExchangeHandler",
"subjectToken": "#{attributes.get('bearerToken')}",
"scopes": [
"fr:idm:*"
]
}
}
],
"handler": {
"type": "ReverseProxyHandler",
"config": {
"soTimeout": "5 hours",
"propagateDisconnection": true,
"tls": "ClientTlsOptions-1"
}
}
}
}
}
Note: The route configurations described above are intended for POC/demonstration purposes and will need to be further secured/refactored before deployment in a production environment.
Putting It All Together
Sequence Diagram
The sequence diagram illustrates how an MCP server is onboarded within our PingGateway-driven platform, where PingGateway takes on the responsibility of handling the heavy lifting by centralizing security and enforcing a standards-driven OAuth2 implementation.

Demo
This demo highlights three use cases, leveraging two distinct MCP servers that are secured by PingGateway, which enforces the MCP Authorization specification and performs dynamic client registration as illustrated in the sequence diagram above:
- Identity Lookup: Retrieve identity details through the IReader MCP Server served at http://forgeops.sqoopid.local:9090/sse
- User Disable: Utilize the IWriter MCP Server served at http://forgeops.sqoopid.local:9090/iwriter-sse with the disable tool to deactivate a user.
- User Re-activation: Employ the IWriter MCP Server served at http://forgeops.sqoopid.local:9090/iwriter-sse with the activate tool to restore a user’s access.
Conclusion, Key Takeaways & Next Steps
This demo emphasizes how PingGateway can centralize cross-cutting concerns around authorization, consolidating critical security responsibilities at the gateway. By offloading these security functions, MCP servers remain lean and focused on their core business logic, while PingGateway enforces standards-driven authorization, handles token exchange, and proxies SSE events. This architecture provides a platform perspective to hosting remote MCP servers, simplifying server implementations while ensuring consistent, secure, and scalable access control across the platform.
Key Takeaways
- Always ensure proper safeguards around dynamic client registration to prevent it from being open or unprotected. One effective approach is to require the use of software statements.
- According to the latest MCP specification, MCP server onboarding relies on dynamic client registration. Each Resource Owner (RO) receives a unique OAuth2 client for their MCP server on a given Host. In large deployments, this can lead to extremely high client counts—for example, an Authorization Server managing 1 million ROs could have up to 1 million OAuth2 clients at any time. If a single RO accesses multiple Hosts for the same MCP server, the total number of clients could grow even further. As a result, scaling must be carefully considered, and in my recent discussions on IDPro Slack, it appears that the dynamic client registration requirement of the MCP specification may be removed in the future.
- Additionally, sharing data with a public LLM, as in this example, remains a concern. An air-gapped approach—replacing a public LLM with a private, self-hosted LLM—can help mitigate this risk.
Next Steps
This series began with building MCP servers, and in this article, we explored how to secure MCP servers using a centralized platform approach via PingGateway. In the next article, the demo will be extended to implement fine-grained authorization, demonstrating how OPA (Open Policy Agent) or similar technologies can be used to restrict access to specific tools and further enforce granular control over operations.
Thank you for reading! I love exploring ideas at the nexus of Identity and AI. If you are working on similar problems and would like to collaborate, please reach out via LinkedIn or my Contact Page—I would love to connect. For any questions, feel free to DM me or leave a comment below.
Disclaimer: This article was reviewed using LLM.
Leave a comment