Skip to main content

Securing the MCP endpoint

The Model Context Protocol (MCP) endpoint is an OAuth 2.1 resource server once Spring Security is on the classpath. Add the starter and name the issuer, the audience and the scopes every call needs:

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security-oauth2-resource-server</artifactId>
</dependency>
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://issuer.example.com
audiences: movies-mcp
gatool:
mcp:
security:
baseline-scopes: mcp:tools

Startup stops while any of the three settings above is unset, and the message names the property. Startup also stops when Spring Security is absent and gatool.mcp.security.unsafe.allow-mcp-calls-without-authentication, the unauthenticated switch, is off. That message names the starter. The endpoint reads JWT access tokens only. An opaque token that needs introspection is unsupported, and the stop for a missing issuer-uri says so. Spring Boot's decoder reads the issuer's discovery document at the first request. A wrong issuer-uri therefore surfaces at the first request, and the stop at startup covers an unset property alone. An issuer-uri that cannot be reached, or that answers without a discovery document, gives 500 on every call that carries a token. Spring Security raises JwtDecoderInitializationException there, outside the bearer token handling. An issuer-uri that answers and differs from the iss claim of the token gives 401 with invalid_token. With the issuer, the audience and the scopes set, a call without a token gets a 401. Its challenge names this endpoint's metadata document, /.well-known/oauth-protected-resource/mcp, and the baseline scopes. A client reads the document to find the issuer, and asks for the scopes at sign-in. The document answers anonymously with the issuer and the scopes. The root document at /.well-known/oauth-protected-resource stays unserved. RFC 9728 gives that path the root resource's identifier, and a client reads the URL the challenge names. A request there meets the default chain's 401 with a bearer challenge. A token for another audience gets 401 with invalid_token, because Spring Boot's decoder checks the audience. A token that lacks a baseline scope gets 403 with insufficient_scope. CSRF stays off for the endpoint and its metadata path, since an MCP client authenticates with a bearer token alone.

An application without a SecurityFilterChain of its own gets two from the starter. The first serves the endpoint and its metadata path. The second is a default chain at Spring Boot's basic-auth order, shaped like the chain Boot would have contributed. Every other path then needs a bearer token for the same issuer. This is how Boot's resource server chain works, because Boot's generated user backs off for a JwtDecoder bean. Under the unauthenticated switch without an issuer, the default chain keeps form and basic login against the generated user. The bearer shape runs without an HTTP session, a saved request or CSRF protection. Boot's own chain keeps all three. A request without a token therefore cannot open a session. The actuator's health endpoint stays open where the actuator is present. An error dispatch is left to Boot's error controller. A request the endpoint refuses with a 404 therefore reads Boot's error body in place of a login challenge. Both chains back off as soon as the application declares any chain of its own, which is Boot's rule for its own defaults. An application that declares its own chains gives the endpoint a chain of its own, ordered ahead of the rest. Its matcher covers both paths, and it carries the configurer alone:

@Bean
@Order(1)
SecurityFilterChain mcp(HttpSecurity http) throws Exception {
return http.securityMatcher("/mcp", "/.well-known/oauth-protected-resource/mcp")
.with(McpServerSecurityConfigurer.mcpServer(), withDefaults())
.build();
}

The configurer refuses a chain that already carries authorizeHttpRequests rules. Spring Security registers a configurer's rules at build time, after the chain's own, and the first matching rule wins. A broader rule would therefore decide the endpoint ahead of the scope check. Startup also stops when the chain that serves the endpoint lacks the resource server, and that message names the chain above.

Behind a proxy that terminates TLS, the challenge and the metadata document would name the address the servlet container saw. Set gatool.mcp.security.resource to the URL clients reach, such as https://mcp.example.com/mcp, and both name that URL. Or set server.forward-headers-strategy=native and name the proxy in server.tomcat.remoteip.internal-proxies. The container then reads the forwarded headers from that proxy alone. The default of that property covers every private address range. With the default, any caller on a private network is read as a proxy, and trusted-proxies adds to that list without narrowing it. The framework strategy trusts the forwarded headers every client sends. A caller could then choose the host the challenge names and the address the rate limiter counts calls against. That limiter counts per caller under the same key as the session binding, which ties an MCP session to the caller that opened it. The key is the token's subject once the endpoint is secured, and the client address while the unauthenticated switch is on. The value of gatool.mcp.security.resource is an absolute URL on the endpoint's own path, without a query, a fragment or a trailing slash. Startup stops on any other value. MCP ties the audience to that canonical URL. The issuer should therefore write it as the token's aud, and spring.security.oauth2.resourceserver.jwt.audiences should name it. The audience check belongs to Spring Boot's decoder, so an application that declares a JwtDecoder bean of its own owns that check.

Some issuers name an API by an identifier of their own and write that identifier as the audience. Microsoft Entra ID gives an API an App ID URI, api://<client-id> by default. In a v2.0 access token, the aud claim holds the client ID. In a v1.0 token, it holds the client ID or the App ID URI. Microsoft's claims reference describes both. With such an issuer the two properties take different values. gatool.mcp.security.resource takes the URL of the endpoint, under http or https. RFC 9728 defines the resource identifier as an https URL. Every challenge builds the address of the metadata document from the scheme, host and port of that value. Startup stops on api://<client-id> there, and the message names the URL to set and the property for the identifier. spring.security.oauth2.resourceserver.jwt.audiences takes the identifier as the token carries it. Spring Boot's decoder compares the aud claim with that property alone. A token issued for api://<client-id> is therefore accepted once the property names it, whatever URL the resource property holds.

Once every bean exists, startup reads the live chains. It checks that the metadata document answers an anonymous GET under the chain that serves it. It checks that the chain serving the endpoint is the configurer's own. That catches an application chain ordered ahead of it with a resource server of its own. It checks that the compliance filter, the bean gaToolMcpComplianceFilter, is registered and runs ahead of Spring Security's filter. That filter reads each request ahead of the scope check. Every stop names the line to change.

GATool serves the endpoint with the dispatcher servlet mapped at the root. Startup therefore stops on spring.mvc.servlet.path set to anything else, and on an endpoint written as a pattern. Behind server.servlet.context-path the endpoint and its metadata document carry the context path, and gatool.mcp.security.resource names it in front of the endpoint. The document then sits at the context path followed by the well-known path. Every challenge points there. RFC 9728 would place the well-known path ahead of the whole resource path. A client that follows the challenge finds the document. A client that probes the RFC location behind a context path misses it.

On the stateful transport each session is bound to the caller that opened it, as MCP's security best practices recommend. A request that carries another caller's session id answers 404 with code -32001, the code for a session the server does not serve. A deletion request is treated the same way. That client then starts a session of its own. A request with an id the server does not know gets the same 404 and the same code. This holds on the secured path and with the unauthenticated switch on. The binding key is the token's subject. Where an application's own converter leaves the name blank, the key is the token's fingerprint, a SHA-256 hash of the bearer token. A token that lacks both answers 401. Every request that carries the session id refreshes the binding. GATool forgets the binding when the SDK confirms a deletion, and when the SDK answers 404 for the session. It also forgets a binding that has been silent for three times gatool.mcp.sessions.idle-timeout. That sweep runs at the next session opening, and by then the SDK has evicted the session itself. The SDK writes an exception as the body of a 400, a 404 or a 500. GATool rewrites such a body to the JSON-RPC error alone. The error carries the call's id where the request held one, so a stack trace stays out of every answer.

In production set gatool.mcp.security.resource. Without it, every challenge and the metadata document name the address a request carried in its Host header, which the caller writes. Startup warns about that when the server is bound off loopback.

The caller and the request thread​

GATool reads the caller of a tool call from the thread the call runs on. The rate limiter names the caller from Spring Security's context on that thread. While the unauthenticated switch is on, it names the caller from the servlet request bound to that thread. The token-exchange strategy and the forwarded token, gatool.api.credentials.unsafe.forward-client-tokens, take the caller's token from the same context. The scope check runs earlier, in the security filter chain, so it reads the token of the request itself.

That reading is right while the call runs on the thread that served its HTTP request, and GATool checks that on every call. The MCP SDK hands a synchronous tool handler to a worker thread of Reactor's bounded elastic scheduler, unless the server was built with immediateExecution(true). Spring AI's stateless server bean sets that itself. Its stateful server bean takes the setting from a McpSyncServerCustomizer bean that Spring AI contributes. On a worker thread every caller would share one rate limit. Under Spring Security's MODE_INHERITABLETHREADLOCAL a call could reach the API with the token of another caller.

Startup stops for a server built without immediate execution. The stateful server bean takes one customizer. An application's own McpSyncServerCustomizer bean beside Spring AI's stops startup, because Spring finds two beans for one parameter. The application's bean replaces Spring AI's when it is marked @Primary, or when it is named mcpSyncServerCustomizer, the parameter name of Spring AI's bean method. Immediate execution is then lost, unless the application's bean sets it. GATool reads the setting from every McpSyncServer and McpStatelessSyncServer bean once every singleton exists. A server built without the setting stops startup. The report names the customizer that dropped it and the line to add:

@Bean
@Primary
McpSyncServerCustomizer serverCustomizer() {
return server -> server.immediateExecution(true)
.requestTimeout(Duration.ofSeconds(30));
}

The MCP SDK keeps that setting in a private field, so GATool reads it by reflection. Where a later SDK puts the field out of reach, startup writes one WARN naming the SDK version and carries on. The refusal below still guards such a server.

A call that arrives off the request thread is refused. The compliance filter marks the thread it serves each request on. The mark is a plain thread-local. A child thread does not inherit it, and Reactor's context propagation does not carry it. A tool call that reaches the handler on an unmarked thread is answered with a tool error. The error says that the server ran the call on a thread that does not carry the caller, and gatool.call.outcome records it as off-request-thread. The operator reads one ERROR line a minute for each tool, naming the thread. The check runs on the servlet transports and is off over stdio, where one process is the only caller. It runs whether or not the deployment's rate limiter or credential strategy reads the caller. GATool cannot see what a ToolRateLimiter or ApiCredentialStrategy bean of the application does with the caller.

The check also refuses one application that reads each caller correctly. That application sets spring.reactor.context-propagation=auto, declares its own customizer without immediate execution, and secures the endpoint. It reads the caller on the worker thread through the accessor Spring Security registers. Adding immediateExecution(true) to the customizer serves that application, and the property keeps serving its own Reactor code. Calls on virtual threads are served. With spring.threads.virtual.enabled=true the filter and the handler run on the same virtual thread.

A per-caller credential is refused where the thread's context may belong to another caller. token-exchange and the forwarded token both read the caller from the security context holder strategy. Under Spring Security's default strategy, a token on the thread was put there for the work the thread is doing. A filter, context propagation, or the application's own code put it there. Under MODE_INHERITABLETHREADLOCAL a thread created during one request keeps that request's context for life. Under MODE_GLOBAL one context is shared by every thread. Under either of those two, a call on a thread that is not serving a request is refused. The model reads which strategy is in force. It also reads that the default strategy with spring.reactor.context-propagation=auto carries each caller to the thread its call runs on. This check covers the in-process surface too, where a streamed chat legitimately runs its tools on Spring AI's worker threads.

Two shapes get past that last check. A SecurityContextHolderStrategy bean of an application's own class, and a ListeningSecurityContextHolderStrategy wrapped around another strategy, cannot be classified. GATool treats them as it treats the default. An application makes RequestContextHolder inheritable with setThreadContextInheritable(true) on its RequestContextFilter or DispatcherServlet. A child thread then has a bound request, which is the signal this check reads.

These shapes are refused as well, and each would otherwise be served:

  • A replaced compliance filter. An application may replace the filter inside the compliance filter bean with one of its own. With the unauthenticated switch on, the mark then stays off every thread. Every call is refused, and startup stays silent about it. With the endpoint secured, startup already stops on that set-up.

  • A transport of the application's own. It hands the call to another thread and carries the security context there itself.

  • A call from application code on a thread of its own. In a servlet application, a scheduled job may invoke the handler of one of GATool's SyncToolSpecification beans directly.

  • A streamed call in-process. It runs under MODE_INHERITABLETHREADLOCAL with spring.reactor.context-propagation=auto, and propagation restores the right caller on Spring AI's worker thread.

  • A thread started for the request in-process. The application started it under MODE_INHERITABLETHREADLOCAL, which is the use that strategy exists for. The new thread inherited the right caller.

  • An application without a servlet request in-process. The application runs under MODE_INHERITABLETHREADLOCAL, and a message listener, for example, sets the security context itself before it calls a tool.

Scopes per tool​

An operation file lists every scope its tool needs. An empty list opens the tool to every caller that holds the baseline scopes, the ones gatool.mcp.security.baseline-scopes names for every call:

# Deletes one review. Only a moderator may call this.
mutation DeleteReview($id: ID!) @gatool(scopes: ["reviews:moderate"]) {
deleteReview(id: $id) { id }
}

A tools/call needs the baseline scopes and the tool's own scopes. The startup listing, the list of tools GATool prints at INFO when the application starts, names the effective set beside each tool, baseline first. For a file that declares [] without a baseline, the listing prints open to every caller. The tool list is built once at startup and served to every caller. tools/list therefore shows every tool, with its name, description and input schema, to every caller that holds the baseline scopes. A call to a tool whose scopes the token lacks is refused with the 403 below. The tool's data is only reachable through the call. A call without a token gets a 401. Its scope parameter names the tool's scopes beside the baseline, so a client that signs in for that call asks for everything it needs. A caller without a token therefore learns, from that scope parameter, which tools carry a scope list and which scopes they need. The challenge names them so that a client can ask for them at sign-in. A token that lacks a scope gets 403 with insufficient_scope. The scope parameter of that challenge names what the call needs, together with the scopes of this server the token already holds. A client that signs in again therefore keeps what it had. The description says which of this server's scopes the token held and which it lacked. A step-up is a second sign-in that asks for more scopes. Two refusals of one caller for one missing scope carry the same challenge. A client that compares them can therefore tell a step-up that can succeed from one that cannot. Each 403 is logged at INFO with the tool, the scopes the token held and lacked, and a correlation id. The correlation id is the trace id where Micrometer Tracing runs, and an id of GATool's own otherwise. A 401 is answered without a log line. A scope name is printable ASCII without a space, a quote or a backslash. Startup stops on any other name. A file without the argument needs the baseline alone. That suits an API that sees the real user, through token exchange or through embedded mode. Embedded mode is where the GraphQL API runs inside the same application through Spring for GraphQL. An embedded call skips the servlet filter chain and every URL rule on spring.graphql.http.path. A token that holds the baseline alone can therefore run an operation that a POST to that path would refuse with 403. The controls on an embedded call are these scopes, the WebGraphQlInterceptor beans, and method security in the resolvers. Startup warns while embedded mode runs with Spring Security on the classpath. Under client credentials or a static header, GATool sends a shared credential: one identity for every caller. The API then sees GATool as every caller. Every MCP tool therefore has to list its scopes, an empty list included. Startup stops and names the tools without a list. The dynamic tools, the generated tools of dynamic-three-step, take their list from gatool.dev.experimental.dynamic-operations.required-scopes. Scopes derived from the schema's own authorization directives arrive in a later release.

A client whose step-up fails can be served with gatool.mcp.security.unsafe.request-every-scope-at-sign-in. That property publishes every scope this server knows in the metadata and in every 401. MCP's security best practices call publishing every scope a common mistake, and startup warns.

Over stdio GATool runs each tool call on the thread that reads stdin. The MCP SDK writes every answer through one queue that takes one answer at a time. An answer emitted beside another is dropped. That closes the transport and leaves the server silent while its process stays up. An application that declares its own McpSyncServerCustomizer keeps immediateExecution(true) in it. Startup stops while that setting is left out.

Under stdio GATool sets spring.main.web-application-type=none and spring.main.banner-mode=off while those properties are unset. The application then starts without a web server. An application that serves pages or an actuator of its own beside the stdio server sets spring.main.web-application-type=servlet. Stdio stays the one MCP transport there. The MCP endpoint is absent from the HTTP port, and both of GATool's security chains stay out of the application. With Spring Security on the classpath, Spring Boot's default chain secures the application's own paths.

Over stdio the caller is the process that started the server. gatool.mcp.stdio.granted-scopes, set in the environment, names the scopes that process holds. Startup stops while tools require scopes and the list is unset. A call to a tool that needs a scope outside the list answers with a tool error naming the scope. Console logging is off under stdio, because Spring Boot logs to stdout, which is the JSON-RPC channel. logging.console.enabled=true corrupts the stream. The log goes to stderr, the stream MCP leaves to a stdio server for its logging. A host may capture, forward or ignore it. By default stderr carries WARN and above. The lines of a normal start therefore stay out of it. logging.threshold.console changes that. INFO adds the lines of the start. DEBUG, together with a logging.level setting, adds the DEBUG lines of that logger. logging.pattern.console, logging.charset.console and logging.structured.format.console apply to stderr as they would to the console. The log is written without making the server wait. A host that leaves stderr unread loses lines once the pipe and the queue behind it are full, and the server keeps answering. Only the log GATool attaches on Logback lets the server keep answering in that way. Three other writers wait once the host has left about 64 KB unread. They are a direct write to stderr, the log on java.util.logging, and an application's own Logback or Log4j2 configuration that writes to stderr. A host therefore reads stderr, or discards it when an operator lowers the levels. GATool counts the lines it drops. The first line that gets through again follows a line that holds the count, such as GATool dropped 412 log lines while stderr was not being read. That line is written at WARN under the logger io.gatool.boot.mcp.autoconfigure.LogbackStderrLog. A threshold above WARN, or a level above WARN for that logger, keeps it out. A host that reads again finds it ahead of the next line the server writes. Once the server has stopped, the line sits at the end of the stream. logging.file.name writes the log to a file as well, and that file receives every line. gatool.mcp.stdio.log-to-stderr=false turns the stderr log off. GATool steps back where the application configured the log itself. The application does that by setting logging.console.enabled, by naming a file in logging.config, or by bringing a Logback file under a name Spring Boot finds, such as logback-spring.xml. The stderr log is built on Logback, Spring Boot's default. On Log4j2 the console is off as well. It stays off where logging.threshold.console is set, because Spring Boot's console would write the log to stdout. The log is therefore written only in two cases: the application sets logging.file.name, or its own Log4j2 configuration sends the log to stderr. While logging.file.name, logging.file.path and logging.config are all unset, startup says so in one line on stderr. gatool.mcp.stdio.log-to-stderr=false keeps that line out. What Log4j2 reports about itself, such as a log file it cannot create, is written to stderr. That holds at either value of that property, because the listener Spring Boot registers for those reports writes to stdout. On java.util.logging the log reaches stderr through the console handler of the JDK, which Spring Boot leaves on under logging.console.enabled=false. That handler carries INFO and above, and the thread that logs writes it. A host that leaves stderr unread therefore makes such a server wait once the pipe is full. While the console stays off, a startup failure is still written to stderr. That write bypasses the queue. It carries Boot's failure analysis, or the exception's message chain where every analyzer passes on it. Beside a full pipe, GATool gives up the report after 2 seconds. The exit code is then the only signal.

The scopes a token holds are read from the SCOPE_ authorities Spring's JWT converter grants. An application that converts scopes under another prefix or claim has every call refused until it keeps that prefix.

Microsoft Entra ID. Microsoft Entra ID writes the short name of a scope into a token, such as mcp.tools, according to Microsoft's documentation. It expects a client to ask for the qualified name, with the App ID URI in front of it, such as api://6e74172b/mcp.tools. Setting spring.security.oauth2.resourceserver.jwt.authority-prefix to SCOPE_api://<app-id>/ turns that short name into the authority SCOPE_api://<app-id>/mcp.tools. That authority matches the qualified name a baseline and an operation file's @gatool(scopes:) write with the same App ID URI. The qualified names belong to one tenant. An adopter therefore keeps one set of operation files for each environment. gatool.mcp.operations.locations points at the set for the current environment.

An application's own @McpTool methods keep Spring Security method security once the application adds @EnableMethodSecurity itself. @PreAuthorize takes effect on the MCP server's thread. A denied call comes back as a tool error, without the 403 challenge above.

Calling the API with a credential​

gatool.api.credentials.strategy says how GATool authenticates to the GraphQL API. While the property is unset, GATool calls the API without a credential. That suits a local API and an internal one behind a gateway:

  • static-header sends one header on every call, from gatool.api.credentials.header-name and .header-value, such as an API key. The value has to be one an HTTP client can send. A control character in the value stops startup, such as the line break a secret read from a file ends in. So does a space or a tab at the start or the end of the value. That stop names gatool.api.credentials.header-name, so the secret stays out of the message. A space inside the value, as in Bearer <token>, is fine.

  • client-credentials obtains a token for GATool itself through a Spring Boot client registration named by gatool.api.credentials.client-registration-id. The API sees GATool as the caller of every tool. One token is held for the application until it expires.

  • token-exchange hands the caller's token to the issuer with gatool.api.credentials.audience, gatool.api.credentials.resource, or both. The API receives a token issued for itself that still names the real user. The registration's grant type is urn:ietf:params:oauth:grant-type:token-exchange. GATool keeps one exchanged token per inbound token in memory, looked up by a hash of that token. It drops the expired ones each time it saves one, and holds at most ten thousand. Each entry holds the exchanged token's text, so a busy server holds tens of megabytes of tokens at that cap. A caller who steps up to a token with more scopes therefore gets an exchange of its own. The API sees the new scopes on the next call. A call on a thread without the caller's token, which is a call made outside the secured endpoint, fails as a tool error. The error names the strategy and says the arguments are not the cause. A call under the forwarded token fails the same way. So does a token request the issuer refuses, and that error carries the issuer's error code. The exchange names the caller's token as an access token. RFC 8693 gives that type to a token the receiving authorization server issued. The registration's token endpoint therefore has to belong to the issuer that signed the inbound token. The exchange described here is impersonation: the API sees the user alone. An application that wants delegation, where the API sees both the user and GATool, declares its own ApiCredentialStrategy bean. That bean wraps Spring Security's TokenExchangeOAuth2AuthorizedClientProvider with an actor token.

spring:
security:
oauth2:
client:
registration:
movies:
client-id: gatool-server
client-secret: ${MOVIES_CLIENT_SECRET}
authorization-grant-type: urn:ietf:params:oauth:grant-type:token-exchange
provider: issuer
provider:
issuer:
token-uri: https://issuer.example.com/realms/movies/protocol/openid-connect/token
gatool:
api:
credentials:
strategy: token-exchange
client-registration-id: movies
audience: movies-api

The two OAuth2 strategies need spring-boot-starter-security-oauth2-client. Startup stops when the starter is missing, when the registration is missing, or when the grant type does not match the strategy. The message names the missing or wrong item. Token exchange and the forwarded token need an authenticated caller. Both therefore stop startup over stdio, and while the unauthenticated switch is on. ApiCredentialStrategy is a Spring ClientHttpRequestInterceptor under another name. A bean of that type replaces every built-in strategy. Use it for an API that authenticates in another way.

The test suite runs the whole chain against Keycloak 26.7.3 in a container. A user signs in. The endpoint accepts the realm's tokens. It refuses a missing scope until the client steps up. The movie API receives the exchanged token and the client credentials token. The tests skip themselves where Docker is absent.