Core Concepts
Model Context Protocol
Model Context Protocol (or MCP) is a JSON-RPC based protocol for exposing tools, prompts, and resources to MCP clients. Soklet provides a first-class MCP server implementation that handles the transport, session lifecycle, SSE stream management, simulator testing, and most of the protocol plumbing so your application code can stay focused on endpoint behavior.
At a high level, Soklet's MCP support consists of 3 pieces:
- One or more endpoint classes annotated with
@McpServerEndpoint - One or more annotated or programmatic handlers for tools, prompts, resources, or resource listing
- An
McpServerconfigured on yourSokletConfig
Endpoint Definition
MCP endpoints are not normal Soklet Resource Methods. Instead, you define a class that implements McpEndpoint and decorate it with @McpServerEndpoint.
You may define more than one MCP endpoint in the same application. A single McpServer can host multiple endpoint classes as long as they use distinct paths, and Soklet routes MCP transport requests to the matching endpoint by path.
Within that class, you can expose:
@McpToolfortools/call@McpPromptforprompts/get@McpResourceforresources/read@McpListResourcesforresources/list
Here's a representative endpoint:
@McpServerEndpoint(
path = "/tenants/{tenantId}/mcp",
name = "catalog",
version = "1.0.0",
title = "Catalog MCP",
instructions = "Use read-only mode."
)
public class CatalogEndpoint implements McpEndpoint {
@Override
public McpSessionContext initialize(
McpInitializationContext context,
McpSessionContext session
) {
return session.with(
"tenantId",
context.getEndpointPathParameter("tenantId").orElseThrow()
);
}
@McpTool(name = "sum", description = "Adds numbers.")
public McpToolResult sum(
@McpArgument("count") Integer count,
McpSessionContext sessionContext
) {
String tenantId = sessionContext.get("tenantId", String.class).orElseThrow();
return McpToolResult.builder()
.content(McpTextContent.fromText(
"%s for %s".formatted(count, tenantId)
))
.build();
}
@McpPrompt(
name = "greet",
description = "Creates a greeting.",
title = "Greeting"
)
public McpPromptResult greet(@McpArgument("name") String name) {
return McpPromptResult.fromMessages(
McpPromptMessage.fromAssistantText("Hello %s".formatted(name))
);
}
@McpResource(
uri = "catalog://tenants/{tenantId}/recipes/{recipeId}",
name = "recipe",
mimeType = "text/plain"
)
public McpResourceContents recipe(
@McpEndpointPathParameter("tenantId") String tenantId,
@McpUriParameter("recipeId") String recipeId
) {
return McpResourceContents.fromText(
"catalog://tenants/%s/recipes/%s".formatted(tenantId, recipeId),
"tenant=%s recipe=%s".formatted(tenantId, recipeId),
"text/plain"
);
}
}
@McpServerEndpoint(
path = "/tenants/{tenantId}/mcp",
name = "catalog",
version = "1.0.0",
title = "Catalog MCP",
instructions = "Use read-only mode."
)
public class CatalogEndpoint implements McpEndpoint {
@Override
public McpSessionContext initialize(
McpInitializationContext context,
McpSessionContext session
) {
return session.with(
"tenantId",
context.getEndpointPathParameter("tenantId").orElseThrow()
);
}
@McpTool(name = "sum", description = "Adds numbers.")
public McpToolResult sum(
@McpArgument("count") Integer count,
McpSessionContext sessionContext
) {
String tenantId = sessionContext.get("tenantId", String.class).orElseThrow();
return McpToolResult.builder()
.content(McpTextContent.fromText(
"%s for %s".formatted(count, tenantId)
))
.build();
}
@McpPrompt(
name = "greet",
description = "Creates a greeting.",
title = "Greeting"
)
public McpPromptResult greet(@McpArgument("name") String name) {
return McpPromptResult.fromMessages(
McpPromptMessage.fromAssistantText("Hello %s".formatted(name))
);
}
@McpResource(
uri = "catalog://tenants/{tenantId}/recipes/{recipeId}",
name = "recipe",
mimeType = "text/plain"
)
public McpResourceContents recipe(
@McpEndpointPathParameter("tenantId") String tenantId,
@McpUriParameter("recipeId") String recipeId
) {
return McpResourceContents.fromText(
"catalog://tenants/%s/recipes/%s".formatted(tenantId, recipeId),
"tenant=%s recipe=%s".formatted(tenantId, recipeId),
"text/plain"
);
}
}
Soklet uses the same ValueConverterRegistry for MCP endpoint and resource URI parameters that it uses for ordinary HTTP request binding. That means both annotation-based parameters like @McpEndpointPathParameter UUID tenantId / @McpUriParameter Integer recipeId and programmatic helpers like context.getEndpointPathParameter("tenantId", UUID.class) or resourceContext.getUriParameter("recipeId", Integer.class) share the same conversion rules described in Value Conversions.
Soklet also supports programmatic handler registration via McpHandlerResolver, which is useful when annotations are not enough or when you want to layer handlers onto an endpoint dynamically.
References:
Programmatic Handlers
McpHandlerResolver can discover endpoint classes and then layer programmatic handlers onto them. This is useful when handlers are assembled from modules, generated from configuration, or simply easier to express in code than with annotations.
McpHandlerResolver handlerResolver = McpHandlerResolver
.fromClasses(Set.of(CatalogEndpoint.class))
.withTool(new CatalogSearchToolHandler(), CatalogEndpoint.class)
.withPrompt(new CatalogSummaryPromptHandler(), CatalogEndpoint.class)
.withResource(new CatalogNoteResourceHandler(), CatalogEndpoint.class)
.withResourceList(new CatalogResourceListHandler(), CatalogEndpoint.class);
SokletConfig config = SokletConfig.withMcpServer(
McpServer.withPort(8082)
.handlerResolver(handlerResolver)
.build()
).build();
McpHandlerResolver handlerResolver = McpHandlerResolver
.fromClasses(Set.of(CatalogEndpoint.class))
.withTool(new CatalogSearchToolHandler(), CatalogEndpoint.class)
.withPrompt(new CatalogSummaryPromptHandler(), CatalogEndpoint.class)
.withResource(new CatalogNoteResourceHandler(), CatalogEndpoint.class)
.withResourceList(new CatalogResourceListHandler(), CatalogEndpoint.class);
SokletConfig config = SokletConfig.withMcpServer(
McpServer.withPort(8082)
.handlerResolver(handlerResolver)
.build()
).build();
Programmatic tool handlers provide metadata plus a handle(...) method:
public final class CatalogSearchToolHandler implements McpToolHandler {
@Override
public String getName() {
return "search_catalog";
}
@Override
public String getDescription() {
return "Searches the catalog.";
}
@Override
public McpSchema getInputSchema() {
return McpSchema.object()
.required("query", McpType.STRING)
.optionalEnum("mode", "keyword", "semantic")
.build();
}
@Override
public McpToolResult handle(McpToolHandlerContext context) {
UUID tenantId = context
.getEndpointPathParameter("tenantId", UUID.class)
.orElseThrow();
String query = ((McpString) context.getArguments()
.get("query")
.orElseThrow()).value();
return McpToolResult.builder()
.content(McpTextContent.fromText(
"tenant=%s query=%s".formatted(tenantId, query)
))
.build();
}
}
public final class CatalogSearchToolHandler implements McpToolHandler {
@Override
public String getName() {
return "search_catalog";
}
@Override
public String getDescription() {
return "Searches the catalog.";
}
@Override
public McpSchema getInputSchema() {
return McpSchema.object()
.required("query", McpType.STRING)
.optionalEnum("mode", "keyword", "semantic")
.build();
}
@Override
public McpToolResult handle(McpToolHandlerContext context) {
UUID tenantId = context
.getEndpointPathParameter("tenantId", UUID.class)
.orElseThrow();
String query = ((McpString) context.getArguments()
.get("query")
.orElseThrow()).value();
return McpToolResult.builder()
.content(McpTextContent.fromText(
"tenant=%s query=%s".formatted(tenantId, query)
))
.build();
}
}
The other programmatic contracts follow the same pattern:
McpPromptHandlerusesMcpPromptHandlerContextforprompts/getMcpResourceHandlerusesMcpResourceHandlerContextforresources/read, includinggetRequestedUri()and typedgetUriParameter(...)McpResourceListHandlerusesMcpResourceListHandlerContextforresources/list, includingMcpListResourcesContextand the incoming pagination cursor
McpSchema is Soklet's JSON-schema DSL for programmatic MCP handler metadata. McpSchema.object() supports required and optional scalar properties plus enum-backed string properties through required(...), optional(...), requiredEnum(...), and optionalEnum(...).
Configuration
MCP runs on its own McpServer. Like Soklet's SSE support, this is a separate server and cannot share the same port as any regular HttpServer you might also configure.
SokletConfig config = SokletConfig.withMcpServer(
McpServer.withPort(8082)
.handlerResolver(McpHandlerResolver.fromClasspathIntrospection())
.build()
).build();
SokletConfig config = SokletConfig.withMcpServer(
McpServer.withPort(8082)
.handlerResolver(McpHandlerResolver.fromClasspathIntrospection())
.build()
).build();
If the same application also serves ordinary HTTP Resource Methods, add .httpServer(HttpServer.fromPort(8080)) to the builder.
For tests or small examples, McpHandlerResolver::fromClasses is often more convenient:
McpServer.withPort(0)
.handlerResolver(McpHandlerResolver.fromClasses(
Set.of(CatalogEndpoint.class)
))
.build();
McpServer.withPort(0)
.handlerResolver(McpHandlerResolver.fromClasses(
Set.of(CatalogEndpoint.class)
))
.build();
If your application exposes multiple MCP endpoints, register all of them with the same resolver:
McpServer.withPort(8082)
.handlerResolver(McpHandlerResolver.fromClasses(
Set.of(CatalogEndpoint.class, AdminEndpoint.class)
))
.build();
McpServer.withPort(8082)
.handlerResolver(McpHandlerResolver.fromClasses(
Set.of(CatalogEndpoint.class, AdminEndpoint.class)
))
.build();
Each endpoint keeps its own path, session namespace, and serverInfo metadata. For example, /tenants/{tenantId}/mcp and /admin/mcp can coexist on the same MCP server.
The builder exposes the expected transport knobs: request timeout, handler timeout, handler concurrency, queue capacity, request size limits, heartbeat interval, write timeout, concurrent connection limits, custom session stores, custom ID generation, request admission, request interception, and custom MCP CORS authorization.
For clustered deployments, a custom McpSessionStore is usually appropriate. Redis-backed and SQL-backed implementations are both reasonable choices, as long as they preserve the compare-and-set semantics of the store contract.
Browser Clients
The default McpCorsAuthorizer is nonBrowserClientsOnlyInstance, which keeps browser CORS disabled by default.
To allow browser-based MCP clients, configure an McpCorsAuthorizer explicitly:
McpServer.withPort(8082)
.handlerResolver(McpHandlerResolver.fromClasses(
Set.of(CatalogEndpoint.class)
))
.corsAuthorizer(McpCorsAuthorizer.fromWhitelistedOrigins(
Set.of("https://chat.openai.com"),
origin -> true
))
.build();
McpServer.withPort(8082)
.handlerResolver(McpHandlerResolver.fromClasses(
Set.of(CatalogEndpoint.class)
))
.corsAuthorizer(McpCorsAuthorizer.fromWhitelistedOrigins(
Set.of("https://chat.openai.com"),
origin -> true
))
.build();
That enables OPTIONS preflight handling plus Access-Control-* response headers on MCP POST, GET, and DELETE responses for the configured origins.
Error Mapping
McpEndpoint gives you two override points for customizing framework-generated MCP errors.
Use McpEndpoint::handleToolError for exceptions thrown during tools/call:
@Override
public McpToolResult handleToolError(
Throwable throwable,
McpToolCallContext context
) {
return McpToolResult.fromErrorMessage(
"catalog tool failed: %s".formatted(throwable.getMessage())
);
}
@Override
public McpToolResult handleToolError(
Throwable throwable,
McpToolCallContext context
) {
return McpToolResult.fromErrorMessage(
"catalog tool failed: %s".formatted(throwable.getMessage())
);
}
Use McpEndpoint::handleError for non-tool MCP failures such as initialize, prompts/get, resources/list, or resources/read:
@Override
public McpJsonRpcError handleError(
Throwable throwable,
McpRequestContext context
) {
return McpJsonRpcError.fromCodeAndMessage(
-32001,
"Catalog request failed"
);
}
@Override
public McpJsonRpcError handleError(
Throwable throwable,
McpRequestContext context
) {
return McpJsonRpcError.fromCodeAndMessage(
-32001,
"Catalog request failed"
);
}
If you do not override them, Soklet's defaults are intentionally conservative: tool failures become an error-valued McpToolResult, while non-tool failures become a JSON-RPC -32603 Internal error.
Structured Tool Responses
McpToolResult can include both human-readable text content and optional structuredContent. This is the MCP field intended for machine-readable tool results.
By default, Soklet expects structuredContent to already be expressed as Soklet MCP values such as McpObject, McpArray, McpString, and McpNumber. If you want to return arbitrary POJOs or records instead, configure a custom McpResponseMarshaler on your McpServer.
Do not pass gson.toJson(...) or other raw JSON strings into structuredContent. MCP structured content should be an object or array tree, not a pre-serialized JSON string.
Native McpValue Trees
If you are comfortable building the MCP value tree directly, no extra marshaling setup is required:
@McpTool(name = "lookup_recipe", description = "Looks up a recipe.")
public McpToolResult lookupRecipe(@McpArgument("recipeId") String recipeId) {
return McpToolResult.builder()
.content(McpTextContent.fromText("Recipe found: tomato soup"))
.structuredContent(new McpObject(Map.of(
"recipeId", new McpString(recipeId),
"title", new McpString("Tomato Soup"),
"servings", new McpNumber(BigDecimal.valueOf(4)),
"tags", new McpArray(List.of(
new McpString("soup"),
new McpString("vegetarian")
))
)))
.build();
}
@McpTool(name = "lookup_recipe", description = "Looks up a recipe.")
public McpToolResult lookupRecipe(@McpArgument("recipeId") String recipeId) {
return McpToolResult.builder()
.content(McpTextContent.fromText("Recipe found: tomato soup"))
.structuredContent(new McpObject(Map.of(
"recipeId", new McpString(recipeId),
"title", new McpString("Tomato Soup"),
"servings", new McpNumber(BigDecimal.valueOf(4)),
"tags", new McpArray(List.of(
new McpString("soup"),
new McpString("vegetarian")
))
)))
.build();
}
That produces an MCP tool result whose structuredContent is an actual JSON object:
{
"jsonrpc": "2.0",
"id": "req-2",
"result": {
"content": [
{
"type": "text",
"text": "Recipe found: tomato soup"
}
],
"isError": false,
"structuredContent": {
"recipeId": "recipe-123",
"title": "Tomato Soup",
"servings": 4,
"tags": ["soup", "vegetarian"]
}
}
}
{
"jsonrpc": "2.0",
"id": "req-2",
"result": {
"content": [
{
"type": "text",
"text": "Recipe found: tomato soup"
}
],
"isError": false,
"structuredContent": {
"recipeId": "recipe-123",
"title": "Tomato Soup",
"servings": 4,
"tags": ["soup", "vegetarian"]
}
}
}
JSON Conversion
If your application already models tool results as normal Java objects, configure a custom McpResponseMarshaler that converts those objects into Soklet's McpValue tree.
For example, with Gson:
public final class GsonMcpResponseMarshaler implements McpResponseMarshaler {
private final Gson gson;
public GsonMcpResponseMarshaler(Gson gson) {
this.gson = Objects.requireNonNull(gson);
}
@Override
public McpValue marshalStructuredContent(
Object value,
McpStructuredContentContext context
) {
if (value == null)
return McpNull.INSTANCE;
if (value instanceof McpValue mcpValue)
return mcpValue;
return toMcpValue(this.gson.toJsonTree(value));
}
private McpValue toMcpValue(JsonElement jsonElement) {
if (jsonElement == null || jsonElement.isJsonNull())
return McpNull.INSTANCE;
if (jsonElement.isJsonObject()) {
Map<String, McpValue> values = new LinkedHashMap<>();
for (Map.Entry<String, JsonElement> entry : jsonElement
.getAsJsonObject()
.entrySet()) {
values.put(entry.getKey(), toMcpValue(entry.getValue()));
}
return new McpObject(values);
}
if (jsonElement.isJsonArray()) {
List<McpValue> values = new ArrayList<>();
for (JsonElement child : jsonElement.getAsJsonArray()) {
values.add(toMcpValue(child));
}
return new McpArray(values);
}
JsonPrimitive primitive = jsonElement.getAsJsonPrimitive();
if (primitive.isBoolean())
return new McpBoolean(primitive.getAsBoolean());
if (primitive.isNumber())
return new McpNumber(primitive.getAsBigDecimal());
return new McpString(primitive.getAsString());
}
}
public final class GsonMcpResponseMarshaler implements McpResponseMarshaler {
private final Gson gson;
public GsonMcpResponseMarshaler(Gson gson) {
this.gson = Objects.requireNonNull(gson);
}
@Override
public McpValue marshalStructuredContent(
Object value,
McpStructuredContentContext context
) {
if (value == null)
return McpNull.INSTANCE;
if (value instanceof McpValue mcpValue)
return mcpValue;
return toMcpValue(this.gson.toJsonTree(value));
}
private McpValue toMcpValue(JsonElement jsonElement) {
if (jsonElement == null || jsonElement.isJsonNull())
return McpNull.INSTANCE;
if (jsonElement.isJsonObject()) {
Map<String, McpValue> values = new LinkedHashMap<>();
for (Map.Entry<String, JsonElement> entry : jsonElement
.getAsJsonObject()
.entrySet()) {
values.put(entry.getKey(), toMcpValue(entry.getValue()));
}
return new McpObject(values);
}
if (jsonElement.isJsonArray()) {
List<McpValue> values = new ArrayList<>();
for (JsonElement child : jsonElement.getAsJsonArray()) {
values.add(toMcpValue(child));
}
return new McpArray(values);
}
JsonPrimitive primitive = jsonElement.getAsJsonPrimitive();
if (primitive.isBoolean())
return new McpBoolean(primitive.getAsBoolean());
if (primitive.isNumber())
return new McpNumber(primitive.getAsBigDecimal());
return new McpString(primitive.getAsString());
}
}
Then wire it into your MCP server:
Gson gson = new Gson();
SokletConfig config = SokletConfig.withMcpServer(
McpServer.withPort(8082)
.handlerResolver(McpHandlerResolver.fromClasses(
Set.of(CatalogEndpoint.class)
))
.responseMarshaler(new GsonMcpResponseMarshaler(gson))
.build()
).build();
Gson gson = new Gson();
SokletConfig config = SokletConfig.withMcpServer(
McpServer.withPort(8082)
.handlerResolver(McpHandlerResolver.fromClasses(
Set.of(CatalogEndpoint.class)
))
.responseMarshaler(new GsonMcpResponseMarshaler(gson))
.build()
).build();
Now your tool can return a normal app-owned type as structuredContent:
public record RecipeLookupResponse(
String recipeId,
String title,
Integer servings,
List<String> tags
) {}
@McpTool(name = "lookup_recipe", description = "Looks up a recipe.")
public McpToolResult lookupRecipe(@McpArgument("recipeId") String recipeId) {
RecipeLookupResponse response = new RecipeLookupResponse(
recipeId,
"Tomato Soup",
4,
List.of("soup", "vegetarian")
);
return McpToolResult.builder()
.content(McpTextContent.fromText("Recipe found: tomato soup"))
.structuredContent(response)
.build();
}
public record RecipeLookupResponse(
String recipeId,
String title,
Integer servings,
List<String> tags
) {}
@McpTool(name = "lookup_recipe", description = "Looks up a recipe.")
public McpToolResult lookupRecipe(@McpArgument("recipeId") String recipeId) {
RecipeLookupResponse response = new RecipeLookupResponse(
recipeId,
"Tomato Soup",
4,
List.of("soup", "vegetarian")
);
return McpToolResult.builder()
.content(McpTextContent.fromText("Recipe found: tomato soup"))
.structuredContent(response)
.build();
}
This approach is often the best fit when your application already uses a JSON library like Gson elsewhere and you want MCP structured content to follow the same serialization rules.
Transport And Session Model
Soklet owns the MCP transport for each path declared by @McpServerEndpoint. Your endpoint code does not implement the transport methods directly.
For example, if one endpoint path is /tenants/{tenantId}/mcp, Soklet handles POST, GET, and DELETE on that path. If another endpoint path is /admin/mcp, Soklet separately handles the same transport methods there too.
POST to the MCP endpoint
POST requests to the MCP endpoint handle JSON-RPC messages:
initializepingtools/listtools/callprompts/listprompts/getresources/listresources/templates/listresources/readnotifications/initialized
Important rules:
Content-Typemust beapplication/jsonAcceptmust allow bothapplication/jsonandtext/event-streaminitializecreates the session and returnsMCP-Session-Id- after
initialize, all MCP requests must send bothMCP-Session-IdandMCP-Protocol-Version - before
notifications/initialized, onlyinitialize,notifications/initialized, andpingare allowed
If a tool call uses McpProgressReporter via an incoming _meta.progressToken, Soklet upgrades that POST response to text/event-stream, emits notifications/progress, then emits the terminal JSON-RPC response on the same stream.
GET from the MCP endpoint
GET requests to the MCP endpoint establish a session-bound SSE stream for outbound server messages:
Acceptmust allowtext/event-streamMCP-Session-IdandMCP-Protocol-Versionare required- the session must already be initialized and have received
notifications/initialized Last-Event-IDis rejected because resumability and replay are not implemented yet
Multiple live GET streams per session are allowed, but a given server-originated message is routed to one stream, not broadcast to every open stream for that session.
DELETE the MCP session
DELETE requests to the MCP endpoint terminate an MCP session:
MCP-Session-IdandMCP-Protocol-Versionare required- a successful delete returns
204 No Content - live
GETstreams for that session are closed
Clustered Deployments
The default McpSessionStore is in-memory. That is fine for a single-node deployment, and it can also work in front of a load balancer if all MCP requests for a given session stay on the same Soklet node.
For a real distributed deployment, you will usually want:
- A shared
McpSessionStore, commonly backed by Redis or SQL - Session-affine routing keyed by
MCP-Session-Id
That second requirement matters because Soklet stores the live MCP GET stream on the node that accepted it. A shared session store makes the session metadata available cluster-wide, but it does not make the open stream socket cluster-wide.
The recommended pattern is:
initializemay land on any node and creates the session in the shared store.- The edge proxy or gateway records which node owns that
MCP-Session-Id, or consistently routes that session to the same node by hashing onMCP-Session-Id. - Later
POST,GET, andDELETErequests for that session are routed back to the same node.
This keeps session metadata and live stream ownership aligned, which is the best fit for Soklet's MCP architecture.
In practice, the most common way to do this is to place Soklet behind an ingress or proxy that supports consistent hashing on request headers, using MCP-Session-Id as the hash key. Proxies such as Envoy, NGINX, and HAProxy are a better fit for that model than an AWS Application Load Balancer, which can match on headers for coarse routing but does not provide per-header-value affinity to a specific target.
If you need true non-sticky cluster transparency, you would need more than a custom session store. You would also need your own distributed stream-ownership and message-delivery control plane so one node can tell another node to write to or close the live stream. Soklet does not provide that layer out of the box.
One final practical note: McpSessionContext can hold arbitrary Java objects. If you are writing a distributed McpSessionStore, keep the stored session context small and serializable, or store lightweight IDs there and load heavier state from your own backing systems.
Automated Testing
Soklet offers first-class MCP simulator support. See the Testing documentation for full examples.
The short version:
- Use
Simulator::performMcpRequest - Expect either
McpRequestResult.ResponseCompletedorMcpRequestResult.StreamOpened - Use
Simulator::onMcpStreamErrorto surface consumer failures in simulator-mode streams
Implementation Status
Soklet implements a substantial subset of the MCP 2025-11-25 Specification, including the core transport, session lifecycle, tools, prompts, resources, resource templates, and progress reporting. Some optional and more advanced features are not yet implemented; see below for details.
Implemented Now
- MCP server transport over dedicated
POST,GET, andDELETEhandling on each configured MCP endpoint path - Session creation, protocol negotiation, idle expiry, explicit deletion, and server-stop termination
- Framework-managed
initialize,notifications/initialized, andping - Annotated and programmatic tool handlers
- Annotated and programmatic prompt handlers
- Annotated and programmatic resource-read handlers
- Annotated and programmatic
resources/listhandlers with optional pagination cursor support resources/templates/list- Request-scoped progress reporting for tool calls, including automatic
POSTupgrade to SSE when a progress token is present - Simulator support for ordinary MCP requests,
GETstreams, and progress-upgradedPOSTstreams - MCP-specific
LifecycleObserverandMetricsCollectorhooks - Browser-oriented MCP CORS via
McpCorsAuthorizer, includingOPTIONSpreflight handling andAccess-Control-*response headers on MCP transport responses - Admission control and interception via
McpRequestAdmissionPolicyandMcpRequestInterceptor
Not Implemented Yet
These are the main items that are not currently supported. Some might be supported in the future, others might not - MCP is rapidly evolving and committing "too early" to features with moving parts might result in an awkward Soklet API, so the design goal is to implement the "core" and evaluate enhancements case-by-case in the future.
tools.listChanged,prompts.listChanged,resources.listChanged, andresources.subscribe- Public session-scoped outbound notifications beyond request-scoped progress reporting
- Formal support for resumability and replay for MCP
GETstreams, includingLast-Event-ID - Richer content blocks such as image, audio, resource-link, embedded-resource, and annotation support
- Optional JSON-RPC
error.data - Built-in authorization and principal modeling
- Broader progress-reporting surfaces beyond active tool calls
- JSON-RPC batch handling
logging,completions,tasks, andexperimentalcapabilities- Additional retention policies for the default in-memory
McpSessionStore

