Soklet Logo

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:

  1. One or more endpoint classes annotated with @McpServerEndpoint
  2. One or more annotated or programmatic handlers for tools, prompts, resources, or resource listing
  3. An McpServer configured on your SokletConfig

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:

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"
    );
  }
}

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();

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();
  }
}

The other programmatic contracts follow the same pattern:

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();

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();

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();

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();

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())
  );
}

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"
  );
}

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();
}

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"]
    }
  }
}

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());
  }
}

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();

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();
}

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:

  • initialize
  • ping
  • tools/list
  • tools/call
  • prompts/list
  • prompts/get
  • resources/list
  • resources/templates/list
  • resources/read
  • notifications/initialized

Important rules:

  • Content-Type must be application/json
  • Accept must allow both application/json and text/event-stream
  • initialize creates the session and returns MCP-Session-Id
  • after initialize, all MCP requests must send both MCP-Session-Id and MCP-Protocol-Version
  • before notifications/initialized, only initialize, notifications/initialized, and ping are 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:

  • Accept must allow text/event-stream
  • MCP-Session-Id and MCP-Protocol-Version are required
  • the session must already be initialized and have received notifications/initialized
  • Last-Event-ID is 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-Id and MCP-Protocol-Version are required
  • a successful delete returns 204 No Content
  • live GET streams 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:

  1. initialize may land on any node and creates the session in the shared store.
  2. The edge proxy or gateway records which node owns that MCP-Session-Id, or consistently routes that session to the same node by hashing on MCP-Session-Id.
  3. Later POST, GET, and DELETE requests 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:

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, and DELETE handling on each configured MCP endpoint path
  • Session creation, protocol negotiation, idle expiry, explicit deletion, and server-stop termination
  • Framework-managed initialize, notifications/initialized, and ping
  • Annotated and programmatic tool handlers
  • Annotated and programmatic prompt handlers
  • Annotated and programmatic resource-read handlers
  • Annotated and programmatic resources/list handlers with optional pagination cursor support
  • resources/templates/list
  • Request-scoped progress reporting for tool calls, including automatic POST upgrade to SSE when a progress token is present
  • Simulator support for ordinary MCP requests, GET streams, and progress-upgraded POST streams
  • MCP-specific LifecycleObserver and MetricsCollector hooks
  • Browser-oriented MCP CORS via McpCorsAuthorizer, including OPTIONS preflight handling and Access-Control-* response headers on MCP transport responses
  • Admission control and interception via McpRequestAdmissionPolicy and McpRequestInterceptor

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, and resources.subscribe
  • Public session-scoped outbound notifications beyond request-scoped progress reporting
  • Formal support for resumability and replay for MCP GET streams, including Last-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, and experimental capabilities
  • Additional retention policies for the default in-memory McpSessionStore

References:

Previous
Server-Sent Events (SSE)