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
SyncToolSpecificationbeans directly. -
A streamed call in-process. It runs under
MODE_INHERITABLETHREADLOCALwithspring.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-headersends one header on every call, fromgatool.api.credentials.header-nameand.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 namesgatool.api.credentials.header-name, so the secret stays out of the message. A space inside the value, as inBearer <token>, is fine. -
client-credentialsobtains a token for GATool itself through a Spring Boot client registration named bygatool.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-exchangehands the caller's token to the issuer withgatool.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 isurn: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 ownApiCredentialStrategybean. That bean wraps Spring Security'sTokenExchangeOAuth2AuthorizedClientProviderwith 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.