|
This version is still in development and is not considered stable yet. For the latest stable version, please use Spring AI 2.0.2! |
MCP 2026-07-28 ("Modern") Servers
The MCP server Boot starters can expose your tools, resources, prompts and completions through a server that implements the 2026-07-28 MCP specification revision. This revision is a redesign of the protocol:
-
It is stateless. There is no initialize handshake and no session. Every request carries the protocol version and the client capabilities in its
_metablock, and the client learns what the server offers throughserver/discover. -
It is POST only. A request is answered with a JSON document, or with a short-lived
text/event-streamwhen the handler pushes progress notifications before its result. -
Server-initiated input (elicitation, sampling, roots) is a multi round-trip request (MRTR). A handler answers with an
InputRequiredResult, and the client retries the same call with the collectedinputResponsesand the server’s sealedrequestState. -
List-changed and resource-updated notifications are delivered over a dedicated
subscriptions/listenstream. -
Logging notifications, ping and in-call elicitation or sampling no longer exist.
The modern server is selected with spring.ai.mcp.server.protocol=MODERN and is available for WebMVC, WebFlux and STDIO.
The same annotated beans and `ToolCallback`s you use with the other protocols are served unchanged.
The modern server requires the MCP Java SDK 2.2.0 line or later, and only the synchronous API type (spring.ai.mcp.server.type=SYNC) is supported at the moment. Setting type=ASYNC together with protocol=MODERN fails the application at startup rather than starting without a server.
|
WebMVC Server
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
</dependency>
spring:
ai:
mcp:
server:
protocol: MODERN
modern:
mcp-endpoint: /mcp
Configuration Properties
The common properties (spring.ai.mcp.server.name, version, instructions, type, capabilities.*, tool-callback-converter, tool-response-mime-type) and the MCP annotation scanner properties apply as they do for the other protocols.
spring.ai.mcp.server.tool-callback-converter=false stops ToolCallback beans from being exposed as MCP tools. It does not affect @McpTool, @McpResource, @McpPrompt or @McpComplete methods, which are controlled by the annotation scanner property spring.ai.mcp.server.annotation-scanner.enabled.
The following properties are specific to the modern server.
| Property | Description | Default |
|---|---|---|
|
The HTTP endpoint the server listens on (POST only). |
|
|
The protocol versions accepted in |
|
|
How long clients may cache the |
|
|
Who may reuse a cached |
|
|
How long clients may cache |
|
|
Who may reuse cached list results. |
|
|
Maximum number of entries per list page served by the in-memory repositories. Unset returns everything on one page. |
unset |
|
Interval between keep-alive comments on streaming responses. Zero disables keep-alives. |
|
|
Maximum accepted request body size. |
|
|
The secret used to seal the multi round-trip |
- |
|
How long a sealed |
SDK default |
|
Whether |
|
|
Whether the server advertises per-resource subscriptions ( |
|
Programming Model
Tools, Resources, Prompts and Completions
Beans with @McpTool, @McpResource, @McpPrompt and @McpComplete methods, as well as ToolCallback and ToolCallbackProvider beans, are exposed exactly as on the stateless server.
Methods keep their return types. Results typed with the legacy McpSchema classes are converted to the modern schema on the way out, so one set of beans can serve both server generations.
The conversion is a compatibility floor, not a ceiling. Every primitive also accepts the modern types directly, and those pass through untouched:
| Primitive | Modern parameters | Modern return types |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
Use the modern types whenever a feature has no legacy equivalent: multi round-trip requests on any primitive, and per-response cache hints on resource reads.
For resources whose cache hints are static, set them on the annotation instead. ttlMs (default 0, not cacheable) and cacheScope (default PRIVATE) are applied to every legacy return value the callback converts. A method that returns a modern ReadResourceResult keeps the hints it built itself. On a legacy server these attributes are ignored with a warning at startup.
@McpResource(uri = "catalog://products", ttlMs = 60_000, cacheScope = CacheScope.PUBLIC)
public String products() {
return catalog.asJson();
}
@McpResource(uri = "catalog://products", name = "products", description = "Product catalog")
public ReadResourceResult products(String uri) {
return ReadResourceResult.builder(List.of(TextResourceContents.builder(uri, loadCatalog()).build()))
.ttlMs(60_000)
.cacheScope(CacheScope.PUBLIC)
.build();
}
Bidirectional parameters of the session-based servers (McpSyncServerExchange, McpSyncRequestContext) are not available, as there is no session to call back on. Methods that declare them are skipped with a warning.
Request Context
A tool, resource, prompt or completion method can declare a McpModernSyncRequestContext parameter. It gives access to the protocol version, the client capabilities and info, the request _meta, the progress token, the transport context and the multi round-trip fields.
Declaring it does not change the shape of the response: the method still answers with a plain JSON result.
Progress and Streaming Responses
On the modern protocol a handler chooses the shape of its response, a single result or a stream of notifications that ends with the result, and the server never turns one into the other.
Annotated methods make the same choice through their signature: a tool, resource or prompt method that declares the SDK McpSyncNotifier parameter is served as a streaming response (text/event-stream), and the notifications it pushes reach the client before the result.
A method without that parameter is a plain result, whatever else it declares.
Completion methods are always plain results and cannot declare the notifier.
@McpTool(name = "index", description = "Indexes the given paths")
public String index(McpSyncNotifier notifier, @McpToolParam(description = "paths") List<String> paths) {
for (int i = 0; i < paths.size(); i++) {
notifier.progress(i + 1, (double) paths.size(), paths.get(i));
index(paths.get(i));
}
return "Indexed " + paths.size() + " paths";
}
The shape is fixed by the signature, not by the request: a method that declares the notifier answers over text/event-stream on every call, as an SDK handler that returns McpSyncResponse.streaming does.
A progress notification is delivered only when the request carried a progressToken in _meta; McpModernSyncRequestContext.progressToken() tells whether it did, and without one the stream carries the result alone.
Declare the notifier only where the method reports progress.
|
Multi Round-Trip Requests
To ask the client for input, return an InputRequiredResult from a tool, resource or prompt method.
The client retries the call with the answers, which the context exposes through inputResponses() and inputResponse(key, type); the latter returns null on the first call, before the client has answered.
The requestState you attach is sealed by the server and verified on retry, so it cannot be tampered with.
@McpTool(name = "book", description = "Books a table")
public CallToolOutcome book(McpModernSyncRequestContext ctx, @McpToolParam(description = "guests") int guests) {
Map<?, ?> confirmation = ctx.inputResponse("confirm", Map.class);
if (confirmation == null) {
return InputRequiredResult.builder()
.elicit("confirm", ElicitFormRequest.builder("Book a table for " + guests + "?",
Map.of("type", "object", "properties", Map.of("ok", Map.of("type", "boolean")))).build())
.requestState("guests=" + guests)
.build();
}
return CallToolResult.builder().addContent(TextContent.builder("Booked").build()).build();
}
Methods that only need the raw fields can declare the modern request type (CallToolRequest, ReadResourceRequest or GetPromptRequest) instead of the context.
Required Client Capabilities
A modern client declares its capabilities in every request, and a server may only ask for input whose capability the request declares.
Declare the capabilities a method cannot work without in requiredCapabilities on @McpTool, @McpResource or @McpPrompt:
@McpTool(name = "summarize", description = "Asks the client's model for a summary",
requiredCapabilities = McpClientCapability.SAMPLING)
public CallToolOutcome summarize(McpModernSyncRequestContext ctx) {
...
}
The server checks the attribute before the method runs and answers a request that lacks any of the capabilities with JSON-RPC error -32021 Missing required client capability, listing the missing ones.
The method does not run, and a method that declares McpSyncNotifier does not open a stream.
Input the method can do without needs no entry; ask for it only when ctx.clientCapabilities() declares it, for example supportsSampling(), and fall back otherwise.
A method that returns an InputRequiredResult asking for an undeclared capability is still answered with -32021, but only after it has run.
The first time this happens for a method, a warning recommends the attribute.
A method that declares requiredCapabilities but returns a type that cannot carry an InputRequiredResult is reported with a warning at startup.
The attribute covers roots, sampling and form and URL elicitation; a method that depends on an experimental or extension capability is registered programmatically with the SDK ClientCapabilities.
The legacy servers ignore the attribute with a warning, as they do for the resource cache hints.
Runtime Changes and Subscriptions
Each primitive is served by an SDK repository bean: McpSyncToolRepository, McpSyncResourceRepository, McpSyncPromptRepository and McpSyncCompletionRepository.
A repository lists definitions and finds a definition together with its handler: McpSyncTool, McpSyncPrompt and McpSyncResource are the SDK’s pairs of definition and handler, and the server validates the request and dispatches to the handler itself.
Unless you define a bean of that type yourself, the auto-configuration registers an in-memory repository per primitive (InMemorySyncToolRepository, InMemorySyncResourceRepository, InMemorySyncPromptRepository, InMemorySyncCompletionRepository) and the McpChangeFeed that backs subscriptions/listen.
Inject an in-memory repository to add or remove primitives at runtime; the matching list-changed notification is broadcast to listening clients.
Call InMemorySyncResourceRepository.resourceUpdated(uri) to notify subscribers of a resource change.
The Primitive List Beans
The annotated methods and the ToolCallback beans do not reach the server directly.
The auto-configuration publishes them as list beans of the SDK primitive types, and the in-memory repositories are built from those lists.
The lists are the contract between the scanning side and the repository side: a repository you define yourself consumes the same lists, and a list bean you define yourself is merged into the in-memory repository.
| Bean type | Produced from | Consumed by |
|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
SyncResourceRegistration and SyncResourceTemplateRegistration pair a resource or template definition with its McpSyncResource, which carries no definition of its own; SyncCompletionRegistration pairs a CompleteReference with its handler.
There can be several beans of each list type, so inject them as ObjectProvider<List<McpSyncTool>> and flatten, as the examples below do.
The ObjectProvider also keeps the repository from being created before the annotation scanner has registered the annotated beans, which an eager List<McpSyncTool> parameter can cause.
@Autowired InMemorySyncToolRepository tools;
void addLater() {
tools.add(McpSyncTool.of(
Tool.builder("late", Map.of("type", "object")).description("Added at runtime").build(),
(ctx, request) -> McpSyncResponse.result(
CallToolResult.builder().addContent(TextContent.builder("late").build()).build())));
}
Filtering per Caller
To show each caller a different subset without giving up the annotated catalogue, define a repository bean of your own that builds the in-memory repository from the same list beans the auto-configuration would use, and wraps it.
The auto-configured in-memory repository backs off because a McpSyncToolRepository bean exists.
A wrapper that filters list and returns null from find hides a primitive completely: it is not listed, and a call, read or get for it answers Unknown tool, Unknown resource or Unknown prompt (-32602), as for a primitive that does not exist.
The transport context the wrapper receives through McpRequestContext is empty unless a McpTransportContextExtractor<ServerRequest> bean fills it (see Customizing the Server and the Transport).
The example below reads X-Role through McpTransportContexts.header(ctx, name), so it needs an extractor that copies that header:
@Bean
McpTransportContextExtractor<ServerRequest> roleHeaderExtractor() {
return request -> McpTransportContexts.fromHeaders(List.of("X-Role"), request.headers().asHttpHeaders()::get);
}
A header is only as trustworthy as whatever sets it: a client can send any X-Role it likes, so a filter like this one belongs behind a gateway or a security filter that overwrites the header after authenticating the caller.
@Bean
McpSyncToolRepository toolRepository(ObjectProvider<List<McpSyncTool>> tools, McpChangeFeed changes) {
McpSyncToolRepository inMemory = InMemorySyncToolRepository.builder()
.tools(tools.stream().flatMap(List::stream).toList())
.changeBroadcaster(changes)
.build();
return new McpSyncToolRepository() {
@Override
public ToolsPage list(McpRequestContext ctx, String cursor) {
ToolsPage page = inMemory.list(ctx, cursor);
List<Tool> allowed = page.tools().stream().filter(tool -> allowed(ctx, tool)).toList();
return new ToolsPage(allowed, page.nextCursor(), page.ttlMs(), CacheScope.PRIVATE);
}
@Override
public McpSyncTool find(McpRequestContext ctx, String name) {
McpSyncTool tool = inMemory.find(ctx, name);
return tool != null && allowed(ctx, tool.definition()) ? tool : null;
}
private boolean allowed(McpRequestContext ctx, Tool tool) {
return "admin".equals(McpTransportContexts.header(ctx, "x-role")) || !tool.name().startsWith("admin_");
}
};
}
The ObjectProvider matters: the List<McpSyncTool> beans come from the annotation scanner, and injecting the lists directly can create the repository before the scanner has run.
A listing that depends on the caller must be returned with cacheScope: private, as above, so that a shared cache never serves one caller’s view to another.
A wrapper that filters a page after the repository has paged it can return a short page; filter before paging, or leave spring.ai.mcp.server.modern.page-size unset so that everything is served on one page.
Resource subscriptions on subscriptions/listen are matched by the SDK without consulting the repository, so a hidden resource’s update notifications still reach a client that subscribed to its URI.
Custom Repositories
To back a primitive with your own store (a database, a remote catalog, a tool gateway), implement the SDK repository interface and define it as a bean.
The interfaces have two methods, list and find; the resource repository adds listTemplates and supportsSubscribe, and find(ctx, uri) serves listed resources and template matches alike.
The in-memory repository for that primitive backs off and the primitive lists are no longer collected for it, so your repository is the single source of truth.
Repositories for the other primitives are unaffected.
The list ttlMs and cacheScope of a page left null come from spring.ai.mcp.server.modern.list-ttl and list-cache-scope.
@Component
public class CatalogToolRepository implements McpSyncToolRepository {
private final ToolCatalog catalog;
public CatalogToolRepository(ToolCatalog catalog) {
this.catalog = catalog;
}
@Override
public ToolsPage list(McpRequestContext ctx, String cursor) {
ToolCatalog.Page page = catalog.page(tenant(ctx), cursor, 100);
return new ToolsPage(page.tools(), page.nextCursor(), null, CacheScope.PRIVATE);
}
@Override
public McpSyncTool find(McpRequestContext ctx, String name) {
ToolCatalog.Entry entry = catalog.find(tenant(ctx), name); // definition + handler, on call
return entry != null ? McpSyncTool.of(entry.tool(), entry::call) : null;
}
private String tenant(McpRequestContext ctx) {
return McpTransportContexts.header(ctx, "x-tenant");
}
}
A McpSyncTool, McpSyncPrompt or McpSyncResource can also declare the client capabilities it cannot work without, with of(definition, requiredCapabilities, handler), the programmatic counterpart of the requiredCapabilities attribute; the server answers a client that did not declare them with -32021 before the handler runs.
For resources, SyncResourceRegistration.of(resource, requiredCapabilities, handler) and SyncResourceTemplateRegistration.of(…) do the same.
Customizing the Server and the Transport
-
A
McpModernServerCustomizerbean receives the SDKMcpServer.Builderbefore the server is built. Use it to add extension features or to replace the request state codec. -
A
RequestStateCodecbean replaces the HMAC codec configured from the properties. -
A
ServerHttpHeaderValidatorbean validatesOriginandHoston every request. The SDK’sDefaultServerTransportSecurityValidatorimplements it. -
The
McpTransportContextthat handlers, repositories andToolCallback`s see is empty by default: no request header, cookie or credential reaches them unless the application puts it there. A `McpTransportContextExtractor<ServerRequest>bean derives the context from the incoming request;McpTransportContexts.fromHeaders(names, request.headers().asHttpHeaders()::get)copies named headers underMcpTransportContexts.HEADERS, whereMcpTransportContexts.header(ctx, name)reads them. The extractor is also the place to resolve the caller once, for example fromServerRequest.principal(), and store a principal instead of raw headers. To bind the sealedrequestStateto that principal too, define aRequestStateCodecbean built withHmacRequestStateCodec.builder().principalExtractor(…); the auto-configured codec binds the state to the method and primitive only. -
The transports are plain
WebMvcModernServerTransportandWebFluxMcpTransportbeans and can be built manually from aMcpRequestManagerwhen more control is needed.
Migration Notes
Compared to the session-based servers:
-
ctx.elicit(…),ctx.sample(…),ctx.roots()andctx.ping()have no direct equivalent. Elicitation, sampling and roots are multi round-trip requests;pingis gone. Logging is deprecated and per request: the client opts in with_meta.logLeveland the server may sendnotifications/messageon that request’s stream, which the modern context does not expose yet. -
Progress requires a streaming response, which a method opts into by declaring
McpSyncNotifier;McpModernSyncRequestContextcarries the request data only. -
Clients must send the
Mcp-Method,MCP-Protocol-Versionand, fortools/call,prompts/getandresources/read, theMcp-Nameheader consistent with the body. Requests that disagree are rejected with JSON-RPC error-32020. -
server/discoverreplacesinitialize; its caching hints and the list caching hints are controlled by the properties above.