Command Palette

Search for a command to run...

UnylyUnyly
Browse all

Soklet

FreeNot checked

Soklet is a zero-dependency Java HTTP/1.1 and Server-Sent Event + MCP server, well-suited for building RESTful APIs and tool-backed agentic systems.

GitHubEmbed

About

Soklet is a zero-dependency Java HTTP/1.1 and Server-Sent Event + MCP server, well-suited for building RESTful APIs and tool-backed agentic systems.

README

Soklet

Maven Central CI Javadoc Changelog

What Is It?

A small HTTP/1.1 server and route handler for Java, well-suited for building RESTful APIs, broadcasting Server-Sent Events, and providing Model Context Protocol (MCP) functionality.

Zero dependencies. Dependency Injection friendly.
Optionally powered by JEP 444: Virtual Threads.

Soklet codes like a library, not a framework.

Note: this README provides a high-level overview of Soklet.
For details, please refer to the official documentation at https://www.soklet.com.

Why?

The Java web ecosystem is missing an HTTP server solution that is dependency-free but offers support for Server-Sent Events (SSE) and Model Context Protocol (MCP) along with hooks for dependency injection and annotation-based request handling. Soklet aims to fill this void.

Soklet provides the plumbing to build "transactional" REST APIs as well as agentic systems that vend results via HTTP response streaming or SSE, and expose tools, prompts, and resources via MCP. It does not make technology choices on your behalf (but an example of how to build a full-featured API is available). It does not natively support Reactive Programming or similar methodologies. It does give you the foundation to build your system, your way.

Soklet is commercially-friendly Open Source Software, proudly powering production systems since 2015.

Design Goals

Design Non-Goals

  • SSL/TLS (your load balancer should provide TLS termination)
  • HTTP/2, HTTP/3 (also handled by your load balancer)
  • WebSockets
  • Dictate which technologies to use (Guice vs. Dagger, Gson vs. Jackson, etc.)
  • "Batteries included" authentication and authorization

Do Zero-Dependency Libraries Interest You?

Similarly-flavored commercially-friendly OSS libraries are available.

  • Pyranid - makes working with JDBC pleasant
  • Lokalized - natural-sounding translations (i18n) via expression language

License

Apache 2.0

Installation

Soklet is a single JAR, available on Maven Central.

JDK 17+ is required (or JDK 21+ for Server-Sent Events and MCP).

Maven

<dependency>
  <groupId>com.soklet</groupId>
  <artifactId>soklet</artifactId>
  <version>3.5.1</version>
</dependency>

Gradle

repositories {
  mavenCentral()
}

dependencies {
  implementation 'com.soklet:soklet:3.5.1'
}

Direct Download

If you don't use Maven or Gradle, you can drop soklet-3.5.1.jar directly into your project. No other dependencies are required.

Code Sample

Here we demonstrate building and running a single-file Soklet application with nothing but the soklet-3.5.1.jar and the JDK. There are no other libraries or frameworks, no Servlet container, no Maven or Gradle build process - no special setup is required.

Soklet systems can be structurally as simple as a "hello world" app.

While a real production system will have more moving parts, this demonstrates that you can build server software without ceremony or dependencies.

package com.soklet.example;

public class App {
  // Canonical example
  @GET("/")
  public String index() {
    return "Hello, world!";
  }

  // Echoes back the path parameter, which must be a LocalDate
  @GET("/echo/{date}")
  public LocalDate echo(@PathParameter LocalDate date) {
    return date;
  }

  // Formats request body locale for display and customizes the response.
  // Example: fr-CA ⇒ francês (Canadá)
  @POST("/language")
  public Response languageFor(@RequestBody Locale locale) {
    Locale systemLocale = Locale.forLanguageTag("pt-BR");
    String contentLanguage = systemLocale.toLanguageTag();

    return Response.withStatusCode(200)
      .body(locale.getDisplayName(systemLocale))
      .headers(Map.of("Content-Language", Set.of(contentLanguage)))
      .cookies(Set.of(
        ResponseCookie.withName("lastRequest")
          .value(Instant.now().toString())
          .httpOnly(true)
          .secure(true)
          .maxAge(Duration.ofMinutes(5))
          .sameSite(SameSite.LAX)
          .build()
      ))
      .build();
  }

  // Start the server and listen on :8080
  public static void main(String[] args) throws Exception {
    // Use out-of-the-box defaults
    SokletConfig config = SokletConfig.withHttpServer(
      HttpServer.fromPort(8080)
    ).build();

    try (Soklet soklet = Soklet.fromConfig(config)) {
      soklet.start();
      System.out.println("Soklet started, press [enter] to exit");
      soklet.awaitShutdown(ShutdownTrigger.ENTER_KEY);
    }
  }
}

Here we use raw javac to build and java to run.

This example requires JDK 17+ to be installed on your machine (or see this example of using Docker for Soklet apps). If you need a JDK, Amazon provides Corretto - a free-to-use-commercially, production-ready distribution of OpenJDK that includes long-term support.

Build

javac -parameters -cp soklet-3.5.1.jar -processor com.soklet.SokletProcessor -d build src/com/soklet/example/App.java

Run

java -cp soklet-3.5.1.jar:build com/soklet/example/App

Test

# Hello, world
% curl -i 'http://localhost:8080/'
HTTP/1.1 200 OK
Content-Length: 13
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT

Hello, world!
# Acceptable path parameter
% curl -i 'http://localhost:8080/echo/2024-12-31'
HTTP/1.1 200 OK
Content-Length: 10
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT

2024-12-31
# Illegal path parameter
% curl -i 'http://localhost:8080/echo/abc'
HTTP/1.1 400 Bad Request
Content-Length: 21
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT

HTTP 400: Bad Request
# Language request body
% curl -i -X POST 'http://localhost:8080/language' -d 'fr-CA'
HTTP/1.1 200 OK
Content-Language: pt-BR
Content-Length: 18
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT
Set-Cookie: lastRequest=2024-04-21T16:19:01.115336Z; Max-Age=300; Secure; HttpOnly; SameSite=Lax

francês (Canadá)

Building Real-World Apps

Of course, real-world apps have more moving parts than a "hello world" example.

The Toy Store App showcases how you might build a robust production system with Soklet.

Feature highlights include:

What Else Does It Do?

Request Handling

Soklet maps HTTP requests to plain Java methods known as Resource Methods (ResourceMethod). Annotate them with @GET, @POST, @PUT, @PATCH, @DELETE, @HEAD, @OPTIONS, or @SseEventSource for SSE. MCP endpoints are declared separately with @McpServerEndpoint and handler annotations like @McpTool, @McpPrompt, and @McpResource. Soklet discovers them at compile time via the SokletProcessor annotation processor, avoiding classpath scans at startup. See the Request Handling docs for details.

Access To Request Data

Resource Methods (ResourceMethod) can accept a Request parameter and inspect HttpMethod values.

@GET("/example")
public void example(Request request /* param name is arbitrary */) {
  // Here, it would be HttpMethod.GET
  HttpMethod httpMethod = request.getHttpMethod();
  // Just the path, e.g. "/example"
  String path = request.getPath();
  // The raw path and query, e.g. "/example?test=123"
  String rawPathAndQuery = request.getRawPathAndQuery();
  // Request body as bytes, if available
  Optional<byte[]> body = request.getBody();
  // Request body marshaled to a string, if available.
  // Charset defined in "Content-Type" header is used to marshal.
  // If not specified, UTF-8 is assumed
  Optional<String> bodyAsString = request.getBodyAsString();
  // Query parameter values by name
  Map<String, Set<String>> queryParameters = request.getQueryParameters();
  // Shorthand for plucking the first query param value by name
  Optional<String> queryParameter = request.getQueryParameter("test");
  // Header values by name (names are case-insensitive)
  Map<String, Set<String>> headers = request.getHeaders();
  // Shorthand for plucking the first header value by name (case-insensitive)
  Optional<String> header = request.getHeader("Accept-Language");
  // Parsed W3C trace context from traceparent/tracestate, if present
  Optional<TraceContext> traceContext = request.getTraceContext();
  // Request cookies by name (names are case-insensitive)
  Map<String, Set<String>> cookies = request.getCookies();
  // Shorthand for plucking the first cookie value by name (case-insensitive)
  Optional<String> cookie = request.getCookie("cookie-name");
  // Form parameters by name (application/x-www-form-urlencoded)
  Map<String, Set<String>> fps = request.getFormParameters();
  // Shorthand for plucking the first form parameter value by name
  Optional<String> fp = request.getFormParameter("fp-name");
  // Is this a multipart request?
  boolean multipart = request.isMultipart();
  // Multipart fields by name
  Map<String, Set<MultipartField>> mpfs = request.getMultipartFields();
  // Shorthand for plucking the first multipart field by name
  Optional<MultipartField> mpf = request.getMultipartField("file-input");
  // CORS information, if available
  Optional<Cors> cors = request.getCors();
  // Ordered locales via Accept-Language parsing
  List<Locale> locales = request.getLocales();
  // Ordered media ranges via Accept parsing; empty means no Accept preference
  List<MediaRange> mediaRanges = request.getMediaRanges();
  // Charset as specified by "Content-Type" header, if available
  Optional<Charset> charset = request.getCharset();
  // Content type component of "Content-Type" header, if available
  Optional<String> contentType = request.getContentType();
}

Value Conversions

Soklet converts textual request inputs to Java types using a ValueConverterRegistry populated with ValueConverter. Conversions are applied to parameters annotated with @QueryParameter, @PathParameter, @RequestHeader, @RequestCookie, @FormParameter, and @Multipart. Supply your own registry (or additional converters) via SokletConfig to support custom types.

Request Body Parsing

Configure a RequestBodyMarshaler however you like - here we accept JSON:

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).requestBodyMarshaler(new RequestBodyMarshaler() {
  // This example uses Google's GSON
  static final Gson GSON = new Gson();

  @NonNull
  @Override
  public Optional<Object> marshalRequestBody(
    @NonNull Request request,
    @NonNull ResourceMethod resourceMethod,
    @NonNull Parameter parameter,
    @NonNull Type requestBodyType
  ) {
    // Let GSON turn the request body into an instance
    // of the specified type.
    //
    // Note that this method has access to all runtime information
    // about the request, which provides the opportunity to, for example,
    // examine annotations on the method/parameter which might
    // inform custom marshaling strategies.
    return Optional.of(GSON.fromJson(
      request.getBodyAsString().orElseThrow(),
      requestBodyType
    ));
  }
}).build();

Then, apply:

public record Employee (
  UUID id,
  String name
) {}

// Accepts a JSON-formatted Record type as input
@POST("/employees")
public void createEmployee(@RequestBody Employee employee) {
  System.out.printf("TODO: create %s\n", employee.name());
}

Response Writing

To control how response data is surfaced to clients (e.g. JSON), provide handler functions (ResourceMethodHandler and ThrowableHandler) to Soklet as shown below.

Alternatively, you can provide your own implementation of ResponseMarshaler for full control.

// Let's use Gson to write response body data
// See https://github.com/google/gson
final Gson GSON = new Gson();

// The request was matched to a Resource Method and executed non-exceptionally
ResourceMethodHandler resourceMethodHandler = (
  @NonNull Request request,
  @NonNull Response response,
  @NonNull ResourceMethod resourceMethod
) -> {
  // Turn response body into JSON bytes with Gson
  Object bodyObject = response.getBody().orElse(null);
  byte[] body = bodyObject == null
    ? null
    : GSON.toJson(bodyObject).getBytes(StandardCharsets.UTF_8);

  // To be a good citizen, set the Content-Type header
  Map<String, Set<String>> headers = new HashMap<>(response.getHeaders());
  headers.put("Content-Type", Set.of("application/json;charset=UTF-8"));

  // Tell Soklet: "OK - here is the final response data to send"
  return MarshaledResponse.withResponse(response)
    .headers(headers)
    .body(body)
    .build();
};

// Function to create responses for exceptions that bubble out
ThrowableHandler throwableHandler = (
  @NonNull Request request,
  @NonNull Throwable throwable,
  @Nullable ResourceMethod resourceMethod
) -> {
  // Keep track of what to write to the response
  String message;
  int statusCode;

  // Examine the exception that bubbled out and determine what
  // the HTTP status and a user-facing message should be.
  // Note: real systems should localize these messages
  switch (throwable) {
    // Soklet throws this exception, a specific subclass of BadRequestException
    case IllegalQueryParameterException e -> {
      message = String.format("Illegal value '%s' for parameter '%s'",
        e.getQueryParameterValue().orElse("[not provided]"),
        e.getQueryParameterName());
      statusCode = 400;
    }

    // Generically handle other BadRequestExceptions
    case BadRequestException ignored -> {
      message = "Your request was improperly formatted.";
      statusCode = 400;
    }

    // Something else?  Fall back to a 500
    default -> {
      message = "An unexpected error occurred.";
      statusCode = 500;
    }
  }

  // Turn response body into JSON bytes with Gson.
  // Note: real systems should expose richer error constructs
  // than an object with a single message field
  byte[] body = GSON.toJson(Map.of("message", message))
    .getBytes(StandardCharsets.UTF_8);

  // Specify our headers
  Map<String, Set<String>> headers = new HashMap<>();
  headers.put("Content-Type", Set.of("application/json;charset=UTF-8"));

  return MarshaledResponse.withStatusCode(statusCode)
    .headers(headers)
    .body(body)
    .build();
};

// Supply our custom handlers to the standard response marshaler
SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).responseMarshaler(ResponseMarshaler.builder()
  .resourceMethod(resourceMethodHandler)
  .throwable(throwableHandler)
  .build()
).build();
Zero-Copy Responses

Already know exactly what you want to send over the wire? Use MarshaledResponse to skip additional processing.

@GET("/example-image.png")
public MarshaledResponse exampleImage() {
  Path imageFile = Path.of("/home/user/test.png");

  // Serve a known-length file response over the wire.
  // Soklet sets Content-Length from the file size; Content-Type remains explicit.
  return MarshaledResponse.withStatusCode(200)
    .body(imageFile)
    .headers(Map.of(
      "Content-Type", Set.of("image/png")
    ))
    .build();
}

MarshaledResponse supports known-length byte-array, file, file-channel, and ByteBuffer bodies. The standard HTTP server can write file-backed responses without first loading the whole file into heap memory. If you already selected a trusted file and want file-response semantics like validators and byte ranges, use MarshaledResponse::withFile; its builder can set Content-Type, Content-Encoding, cache headers, validators, and range behavior. For safe static roots, use StaticFiles instead of hand-rolled path joins; it handles root containment, validators, optional content-hash ETags, access policy, single byte ranges, MIME defaults, and GET/HEAD behavior. Standard HTTP can also opt into gzip compression for finalized in-memory byte-array and ByteBuffer responses with HttpServer.Builder::responseGzipPolicy, including ResponseGzipPolicy::fromDefaultsWithMinimumBodySizeInBytes for common text-like response media types.

Streaming Responses

MarshaledResponse also supports streaming response bodies when the final byte length is not known up front. Streaming is intentionally a marshaled-response feature, like file-backed output: the resource method is taking direct control of what Soklet writes to the HTTP response.

@GET("/tokens")
public MarshaledResponse tokens(TokenService tokenService) {
  return MarshaledResponse.withStatusCode(200)
    .headers(Map.of(
      "Content-Type", Set.of("text/plain; charset=UTF-8"),
      "Cache-Control", Set.of("no-transform")
    ))
    .stream(StreamingResponseBody.fromWriter((output, context) -> {
      try (AutoCloseable ignored = context.onCancel(tokenService::stop)) {
        tokenService.generate(token -> {
          context.throwIfCanceled();
          output.write(token.getBytes(StandardCharsets.UTF_8));
          output.flush();
        });
      }
    }))
    .build();
}

Streaming responses use HTTP/1.1 chunked transfer encoding. Soklet owns Transfer-Encoding, rejects caller-supplied Content-Length, and gives the producer a StreamingResponseContext so upstream work can be canceled when the client disconnects, the server shuts down, or a streaming timeout fires. That context also exposes the originating Request, so producers can use Request::getId for correlation without ambient thread-local state.

Redirects (via Response):

@GET("/example-redirect")
public Response exampleRedirect() {
  // Response has a convenience builder for performing redirects.
  // You could alternatively do this "by hand" by setting HTTP status
  // and headers appropriately.
  return Response.withRedirect(
    RedirectType.HTTP_307_TEMPORARY_REDIRECT, "/other-url"
  ).build();
}

HTTP Server Configuration

Soklet ships with an embedded HTTP/1.1 HttpServer, a dedicated SseServer, and a dedicated McpServer. These builders let you configure host, read/write/handler timeouts, handler concurrency/queueing, request size limits, request decompression, and connection caps; you can also plug in custom request/session IdGenerator, McpSessionStore, and MultipartParser instances. Standard HTTP request-body decompression is disabled by default; enable HttpServer.Builder::requestDecompressionPolicy with RequestDecompressionPolicy::fromDefaults or a custom policy to accept single-coding Content-Encoding: gzip/x-gzip request bodies with decompression-bomb limits. Handlers receive the decompressed bytes through Request::getBody, while Request::getEncodedBodySizeInBytes retains the pre-decompression payload size for telemetry. Configure MCP session ID generation and session caps on the default McpSessionStore builder, or let a custom store own those policies. Provide the configured servers via SokletConfig and see the Server Configuration docs for the full option matrix.

Server-Sent Events (SSE)

SSE endpoints are declared with @SseEventSource and return a SseHandshakeResult, served from a dedicated SseServer port (separate from your standard HTTP server port).

public record ChatMessage(String message) {}

public class ChatResource {
  @SseEventSource("/chat")
  public SseHandshakeResult chat() {
    return SseHandshakeResult.Accepted.builder()
      .clientInitializer(unicaster -> {
        unicaster.unicastEvent(SseEvent.withEvent("hello")
          .data("welcome")
          .build());
      })
      .build();
  }

  @POST("/chat")
  public void postMessage(@RequestBody ChatMessage message,
                          SseServer sseServer) {
    SseBroadcaster broadcaster = sseServer
      .acquireBroadcaster(ResourcePath.fromPath("/chat"))
      .orElseThrow();

    broadcaster.broadcastEvent(SseEvent.withEvent("message")
      .data(message.message())
      .build());
  }
}

Because this example exposes both an SSE event source and a regular POST /chat resource method, it needs both servers:

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).sseServer(
  SseServer.fromPort(8081)
).resourceMethodResolver(
  ResourceMethodResolver.fromClasses(Set.of(ChatResource.class))
).build();

If your application only exposes SSE event source methods, you can omit the regular HTTP server and start with SokletConfig::withSseServer instead.

SSE test via the Simulator (see SseRequestResult):

import org.junit.Assert;
import org.junit.Test;

@Test
public void sseTest() {
  SokletConfig config = SokletConfig.withHttpServer(HttpServer.fromPort(0).build())
    .sseServer(SseServer.fromPort(0))
    .resourceMethodResolver(ResourceMethodResolver.fromClasses(Set.of(ChatResource.class)))
    .build();

  List<SseEvent> events = new ArrayList<>();

  Soklet.runSimulator(config, simulator -> {
    Request request = Request.fromPath(HttpMethod.GET, "/chat");
    SseRequestResult result = simulator.performSseRequest(request);

    if (result instanceof SseRequestResult.HandshakeAccepted accepted) {
      accepted.registerEventConsumer(events::add);

      SseBroadcaster broadcaster = config.getSseServer().orElseThrow()
        .acquireBroadcaster(ResourcePath.fromPath("/chat")).orElseThrow();
      broadcaster.broadcastEvent(SseEvent.withEvent("message")
        .data("hello")
        .build());
    } else {
      throw new IllegalStateException("SSE handshake failed: " + result);
    }
  });

  Assert.assertEquals("hello", events.get(0).getData().orElse(null));
}

Model Context Protocol (MCP)

MCP endpoints are declared with @McpServerEndpoint and expose handlers via @McpTool, @McpPrompt, and @McpResource. They are served from a dedicated McpServer port, while Soklet manages MCP session lifecycle, JSON-RPC transport, SSE stream establishment, and simulator support.

@McpServerEndpoint(
  path = "/catalog/mcp",
  name = "catalog",
  version = "1.0.0",
  title = "Catalog MCP"
)
public class CatalogMcpEndpoint implements McpEndpoint {
  @Override
  public McpSessionContext initialize(McpInitializationContext context,
                                      McpSessionContext session) {
    return session.with("tenantId", "acme");
  }

  @McpTool(name = "lookup_recipe", description = "Looks up a recipe.")
  public McpToolResult lookupRecipe(@McpArgument("recipeId") String recipeId,
                                    McpSessionContext sessionContext) {
    String tenantId = sessionContext.get("tenantId", String.class).orElseThrow();

    return McpToolResult.builder()
      .content(McpTextContent.fromText(
        "Recipe %s for tenant %s".formatted(recipeId, tenantId)
      ))
      .build();
  }
}

Wire up an MCP-only app:

SokletConfig config = SokletConfig.withMcpServer(
  McpServer.withPort(8082)
    .handlerResolver(McpHandlerResolver.fromClasses(Set.of(CatalogMcpEndpoint.class)))
    .build()
).build();

If the same application also serves ordinary HTTP resource methods, add .httpServer(HttpServer.fromPort(8080)) to the builder.

Browser-based MCP clients can be enabled with McpCorsAuthorizer:

SokletConfig config = SokletConfig.withMcpServer(
  McpServer.withPort(8082)
    .handlerResolver(McpHandlerResolver.fromClasses(Set.of(CatalogMcpEndpoint.class)))
    .corsAuthorizer(McpCorsAuthorizer.fromWhitelistedOrigins(
      Set.of("https://chat.openai.com"),
      origin -> true
    ))
    .build()
).build();

That enables OPTIONS preflight handling plus Access-Control-* response headers on MCP POST / GET / DELETE responses for the configured origins. It also authorizes MCP transport requests that carry an Origin header; if Origin is present and the configured McpCorsAuthorizer does not authorize it, Soklet rejects the request with HTTP 403 before running MCP endpoint code or mutating session/stream state. The default nonBrowserClientsOnlyInstance() remains conservative: non-browser clients that omit Origin work normally, while browser-originated MCP transport requests are rejected unless you explicitly allow the origin.

Soklet's MCP v1 support is intentionally conservative: single-request JSON-RPC only, framework-managed initialize, notifications/initialized, notifications/cancelled, and ping, cooperative in-flight request cancelation through McpCancelationToken, framework-generated tools/list / prompts/list / resources/templates/list responses without cursor pagination, and application-backed pagination only for resources/list. JSON-RPC batch arrays and resumable SSE event IDs remain deferred.

MCP test via the Simulator (see McpRequestResult):

import org.junit.Assert;
import org.junit.Test;

@Test
public void mcpTest() {
  SokletConfig config = SokletConfig.withMcpServer(McpServer.withPort(0)
    .handlerResolver(McpHandlerResolver.fromClasses(Set.of(CatalogMcpEndpoint.class)))
    .build()
  ).build();

  Soklet.runSimulator(config, simulator -> {
    McpRequestResult initializeResult = simulator.performMcpRequest(
      Request.withPath(HttpMethod.POST, "/catalog/mcp")
        .headers(Map.of("Content-Type", Set.of("application/json")))
        .body("""
          {
            "jsonrpc":"2.0",
            "id":"req-1",
            "method":"initialize",
            "params":{
              "protocolVersion":"2025-11-25",
              "capabilities":{},
              "clientInfo":{"name":"test-client","version":"1.0.0"}
            }
          }
          """.getBytes(StandardCharsets.UTF_8))
        .build()
    );

    if (!(initializeResult instanceof McpRequestResult.ResponseCompleted initializeResponse))
      throw new IllegalStateException("Expected initialize to complete without opening a stream");

    String sessionId = initializeResponse.getHttpRequestResult().getMarshaledResponse()
      .getHeaders().get("MCP-Session-Id").iterator().next();

    Assert.assertNotNull(sessionId);
  });
}

Form Handling

Frontend:

<form
  enctype="application/x-www-form-urlencoded"
  action="https://example.soklet.com/form?id=123"
  method="POST"
>
  <!-- User can type whatever text they like -->
  <input type="number" name="numericValue" />
  <!-- Multiple values for the same name are supported -->
  <input type="hidden" name="multi" value="1" />
  <input type="hidden" name="multi" value="2" />
  <!-- Names with special characters can be remapped -->
  <textarea name="long-text"></textarea>
  <!-- Note: browsers send "on" string to indicate "checked" -->
  <input type="checkbox" name="enabled" />
  <input type="submit" />
</form>

Backend:

Backend parameters can use @QueryParameter and @FormParameter.

@POST("/form")
public String form(
  @QueryParameter Long id,
  @FormParameter Integer numericValue,
  @FormParameter(optional=true) List<String> multi,
  @FormParameter(name="long-text") String longText,
  @FormParameter String enabled
) {
  // Echo back the inputs
  return List.of(id, numericValue, multi, longText, enabled).stream()
    .map(Object::toString)
    .collect(Collectors.joining("\n"));
}

Test:

% curl -i -X POST 'https://example.soklet.com/form?id=123' \
   -H 'Content-Type: application/x-www-form-urlencoded' \
   -d 'numericValue=456&multi=1&multi=2&long-text=long%20multiline%20text&enabled=on'
HTTP/1.1 200 OK
Content-Length: 37
Content-Type: text/plain; charset=UTF-8
Date: Sun, 21 Mar 2024 16:19:01 GMT

123
456
[1, 2]
long multiline text
on

Multipart Handling

Frontend:

<form
  enctype="multipart/form-data"
  action="https://example.soklet.com/multipart?id=123"
  method="POST"
>
  <!-- User can type whatever text they like -->
  <input type="text" name="freeform" />
  <!-- Multiple values for the same name are supported -->
  <input type="hidden" name="multi" value="1" />
  <input type="hidden" name="multi" value="2" />
  <!-- Prompt user to upload a file -->
  <p>Please attach your document: <input name="doc" type="file" /></p>
  <!-- Multiple file uploads are supported -->
  <p>
    Supplement 1: <input name="extra" type="file" /> Supplement 2:
    <input name="extra" type="file" />
  </p>
  <!-- An optional file -->
  <p>Optionally, attach a photo: <input name="photo" type="file" /></p>
  <input type="submit" value="Upload" />
</form>

Backend:

Backend parameters can use @Multipart and MultipartField.

@POST("/multipart")
public Response multipart(
  @QueryParameter Long id,
  // Multipart fields work like other Soklet params
  // with support for Optional<T>, List<T>, custom names, ...
  @Multipart(optional=true) String freeform,
  @Multipart(name="multi") List<Integer> numbers,
  // The MultipartField type allows access to additional data,
  // like filename and content type (if available).
  // The @Multipart annotation is optional
  // when your parameter is of type MultipartField...
  MultipartField document,
  // ...but is useful if you need to massage the name.
  @Multipart(name="extra") List<MultipartField> supplements,
  // If you specify type byte[] for a @Multipart field,
  // you'll get just its binary data injected
  @Multipart(optional=true) byte[] photo
) {
  // Let's demonstrate the functionality MultipartField provides.

  // Form field name, always available, e.g. "document"
  String name = document.getName();
  // Browser may provide this for files, e.g. "test.pdf"
  Optional<String> filename = document.getFilename();
  // Browser may provide this for files, e.g. "application/pdf"
  Optional<String> contentType = document.getContentType();
  // Field data as bytes, if available
  Optional<byte[]> data = document.getData();
  // Field data as a string, if available
  Optional<String> dataAsString = document.getDataAsString();

  // Apply the standard redirect-after-POST pattern
  return Response.withRedirect(
    RedirectType.HTTP_307_TEMPORARY_REDIRECT, "/thanks"
  ).build();
}

Dependency Injection

In practice, you will likely want to tie in to whatever Dependency Injection library your application uses and have the DI infrastructure vend your instances.

Soklet integrates via an InstanceProvider.

Here's how it might look if you use Google Guice:

// Standard Guice setup
Injector injector = Guice.createInjector(new MyExampleAppModule());

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).instanceProvider(new InstanceProvider() {
  @NonNull
  @Override
  public <T> T provide(@NonNull Class<T> instanceClass) {
    // Have Soklet ask the Guice Injector for the instance
    return injector.getInstance(instanceClass);
  }
}).build();

Now, your Resources are dependency-injected just like the rest of your application is:

public class WidgetResource {
  private WidgetService widgetService;

  @Inject
  public WidgetResource(WidgetService widgetService) {
    this.widgetService = widgetService;
  }

  @GET("/widgets")
  public List<Widget> widgets() {
    return widgetService.findWidgets();
  }
}

Lifecycle Handling and Interception

Implement LifecycleObserver and RequestInterceptor to hook into server and request lifecycles. Use SokletConfig.Builder::lifecycleObservers when you want multiple observers, for example an audit observer plus an OpenTelemetry tracing observer.

HTTP Server Start/Stop: execute code immediately before and after HttpServer startup and shutdown.

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).lifecycleObserver(new LifecycleObserver() {
  @Override
  public void willStartHttpServer(@NonNull HttpServer httpServer) {
    // Perform startup tasks required prior to server launch
    MyPayrollSystem.INSTANCE.startLengthyWarmupProcess();
  }

  @Override
  public void didStartHttpServer(@NonNull HttpServer httpServer) {
    // HTTP server has fully started up and is listening
    System.out.println("HTTP server started.");
  }

  @Override
  public void willStopHttpServer(@NonNull HttpServer httpServer) {
    // Perform shutdown tasks required prior to server teardown
    MyPayrollSystem.INSTANCE.destroy();
  }

  @Override
  public void didStopHttpServer(@NonNull HttpServer httpServer) {
    // HTTP server has fully shut down
    System.out.println("HTTP server stopped.");
  }
}).build();

Request Handling: these methods are fired at the very start of Request processing and the very end, respectively.

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).lifecycleObserver(new LifecycleObserver() {
  @Override
  public void didStartRequestHandling(
    @NonNull ServerType serverType,
    @NonNull Request request,
    @Nullable ResourceMethod resourceMethod
  ) {
    System.out.printf("Received request: %s\n", request);

    // If there was no resourceMethod matching the request, expect a 404
    if(resourceMethod != null)
      System.out.printf("Request to be handled by: %s\n", resourceMethod);
    else
      System.out.println("This will be a 404.");
  }

  @Override
  public void didFinishRequestHandling(
    @NonNull ServerType serverType,
    @NonNull Request request,
    @Nullable ResourceMethod resourceMethod,
    @NonNull MarshaledResponse marshaledResponse,
    @NonNull Duration processingDuration,
    @NonNull List<Throwable> throwables
  ) {
    // We have access to a few things here...
    // * marshaledResponse is what was ultimately sent
    //    over the wire
    // * processingDuration is how long everything took,
    //    including sending the response to the client
    // * throwables is the ordered list of exceptions
    //    thrown during execution (if any)
    long millis = processingDuration.toMillis();
    System.out.printf("Entire request took %dms\n", millis);
  }
}).build();

Request Wrapping: wraps around the whole "outside" of an entire Request handling flow.

Request wrapping runs before Soklet resolves which ResourceMethod should handle the request. If you want to rewrite the HTTP method or path, return a modified Request via the consumer and Soklet will route using the wrapped request. You must call requestProcessor.accept(...) exactly once before returning; otherwise Soklet logs an error and returns a 500 response.

// Special scoped value so anyone can access the current Locale.
// For Java < 21, use ThreadLocal instead
public static final ScopedValue<Locale> CURRENT_LOCALE;

// Spin up the ScopedValue (or ThreadLocal)
static {
  CURRENT_LOCALE = ScopedValue.newInstance();
}

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).requestInterceptor(new RequestInterceptor() {
  @Override
  public void wrapRequest(
    @NonNull ServerType serverType,
    @NonNull Request request,
    @NonNull Consumer<Request> requestProcessor
  ) {
    // Make the locale accessible by other code during this request...
    Locale locale = request.getLocales().get(0);

    // ...by binding it to a ScopedValue (or ThreadLocal).
    ScopedValue.where(CURRENT_LOCALE, locale).run(() -> {
      // You must call this so downstream processing can proceed
      requestProcessor.accept(request);
    });
  }
}).build();

// Then, elsewhere in your code while a request is being processed:

class ExampleService {
  void accessCurrentLocale() {
    // You now have access to the Locale bound to the logical scope
    // (or Thread) without having to pass it down the call stack
    Locale locale = CURRENT_LOCALE.orElse(Locale.getDefault());
  }
}

Request Intercepting (via RequestInterceptor): provides programmatic control over two processing steps.

  1. Invoking the appropriate ResourceMethod to acquire a MarshaledResponse
  2. Sending the MarshaledResponse over the wire to the client

You must call responseWriter.accept(...) exactly once before returning; otherwise Soklet logs an error and returns a 500 response.

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).requestInterceptor(new RequestInterceptor() {
  @Override
  public void interceptRequest(
    @NonNull ServerType serverType,
    @NonNull Request request,
    @Nullable ResourceMethod resourceMethod,
    @NonNull Function<Request, MarshaledResponse> responseGenerator,
    @NonNull Consumer<MarshaledResponse> responseWriter
  ) {
    // Here's where you might start a DB transaction.
    // (MyDatabase is a hypothetical construct)
    MyDatabase.INSTANCE.beginTransaction();

    // Step 1: Invoke the Resource Method and acquire its response
    MarshaledResponse response = responseGenerator.apply(request);

    // Commit the DB transaction before sending the response
    // to reduce contention by keeping "open" time short
    MyDatabase.INSTANCE.commitTransaction();

    // Set a special header on the response via mutable copy
    response = response.copy().headers((mutableHeaders) -> {
      mutableHeaders.put("X-Powered-By", Set.of("Soklet"));
    }).finish();

    // Step 2: Send the finalized response over the wire
    responseWriter.accept(response);
  }
}).build();

Response Writing: monitor the response writing process for each MarshaledResponse - sending bytes over the wire - which may terminate exceptionally (e.g. unexpected client disconnect).

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).lifecycleObserver(new LifecycleObserver() {
  @Override
  public void willStartResponseWriting(
    @NonNull Request request,
    @Nullable ResourceMethod resourceMethod,
    @NonNull MarshaledResponse marshaledResponse
  ) {
    // Access to marshaledResponse here lets us see exactly
    // what will be going over the wire
    Long bodyLength = marshaledResponse.getBodyLength();
    System.out.printf("About to start writing response with " +
      "a %d-byte body...\n", bodyLength);
  }

  @Override
  public void didFinishResponseWriting(
    @NonNull Request request,
    @Nullable ResourceMethod resourceMethod,
    @NonNull MarshaledResponse marshaledResponse,
    @NonNull Duration responseWriteDuration,
    @Nullable Throwable throwable
  ) {
    long millis = responseWriteDuration.toMillis();
    System.out.printf("Took %dms to write response\n", millis);

    // You have access to the throwable that might have occurred
    // while writing the response.  This is useful to, for example,
    // determine trends in unexpected client disconnect rates
    if(throwable != null) {
      System.err.println("Exception occurred while writing response");
      throwable.printStackTrace();
    }
  }
}).build();

CORS Support

CORS is handled by CorsAuthorizer using Cors metadata and returns CorsPreflightResponse / CorsResponse as needed.

Authorize All Origins:

SokletConfig config = SokletConfig.withHttpServer(server)
  // "Wildcard" (*) CORS authorization. Don't use this in production!
  .corsAuthorizer(CorsAuthorizer.acceptAllInstance())
  .build();

Authorize Whitelisted Origins:

Set<String> allowedOrigins = Set.of("https://www.revetware.com");

SokletConfig config = SokletConfig.withHttpServer(server)
  .corsAuthorizer(WhitelistedOriginsCorsAuthorizer.fromOrigins(allowedOrigins))
  .build();

...or be dynamic:

SokletConfig config = SokletConfig.withHttpServer(server)
  .corsAuthorizer(WhitelistedOriginsCorsAuthorizer.fromAuthorizer(
    (origin) -> origin.equals("https://www.revetware.com")
  ))
  .build();

Custom CORS logic:

SokletConfig config = SokletConfig.withHttpServer(server)
  .corsAuthorizer(new CorsAuthorizer() {
    // Any subdomain under soklet.com is permitted
    boolean originMatchesValidSubdomain(@NonNull Cors cors) {
      return cors.getOrigin().matches("https://(.+)\\.soklet\\.com");
    }

    @NonNull
    @Override
    public Optional<CorsPreflightResponse> authorizePreflight(
      @NonNull Request request,
      @NonNull Map<HttpMethod, ResourceMethod> availableResourceMethodsByHttpMethod
    ) {
      // Requests here are guaranteed to have the Cors value set
      Cors cors = request.getCors().orElseThrow();

      // Only greenlight our soklet.com subdomains
      if (originMatchesValidSubdomain(cors))
        return Optional.of(
          CorsPreflightResponse.withAccessControlAllowOrigin(cors.getOrigin())
            .accessControlAllowMethods(availableResourceMethodsByHttpMethod.keySet())
            .accessControlAllowHeaders(Set.of("*"))
            .accessControlAllowCredentials(true)
            .accessControlMaxAge(Duration.ofMinutes(10))
            .build()
        );

      return Optional.empty();
    }

    @NonNull
    @Override
    public Optional<CorsResponse> authorize(@NonNull Request request) {
      // Requests here are guaranteed to have the Cors value set
      Cors cors = request.getCors().orElseThrow();

      // Only greenlight our soklet.com subdomains
      if (originMatchesValidSubdomain(cors))
        return Optional.of(
          CorsResponse.withAccessControlAllowOrigin(cors.getOrigin())
            .accessControlExposeHeaders(Set.of("*"))
            .build()
        );

      return Optional.empty();
    }
  })
  .build();

Unit Testing

First, define something to test:

public class ReverseResource {
  // Reverse the input
  @POST("/reverse")
  public List<Integer> reverse(@RequestBody List<Integer> numbers) {
    return numbers.reversed();
  }

  // Reverse the input and set custom headers/cookies
  @POST("/reverse-again")
  public Response reverseAgain(@RequestBody List<Integer> numbers) {
    Integer largest = Collections.max(numbers);
    Instant lastRequest = Instant.now();

    return Response.withStatusCode(200)
      .headers(Map.of("X-Largest", Set.of(String.valueOf(largest))))
      .cookies(Set.of(
        ResponseCookie.with("lastRequest", lastRequest.toString()).build()
      ))
      .body(numbers.reversed())
      .build();
  }
}

Perform tests:

import org.junit.Assert;
import org.junit.Test;

@Test
public void reverseUnitTest() {
  // Your Resource is a Plain Old Java Object, no Soklet dependency
  ReverseResource resource = new ReverseResource();

  List<Integer> input = List.of(1, 2, 3);
  List<Integer> expected = List.of(3, 2, 1);
  List<Integer> actual = resource.reverse(input);

  Assert.assertEquals("Reverse failed", expected, actual);
}

@Test
public void reverseAgainUnitTest() {
  ReverseResource resource = new ReverseResource();
  List<Integer> input = List.of(1, 2, 3);

  // Set expectations
  List<Integer> expectedBody = List.of(3, 2, 1);
  Integer expectedCode = 200;
  Integer expectedLargest = Collections.max(input);
  Instant lastRequestAfter = Instant.now();

  Response response = resource.reverseAgain(input);

  // Extract actuals
  Integer actualCode = response.getStatusCode();
  List<Integer> actualBody = (List<Integer>) response.getBody().orElseThrow();

  Integer actualLargest = response.getHeaders().get("X-Largest").stream()
    .findAny()
    .map(value -> Integer.valueOf(value))
    .orElseThrow();

  Instant actualLastRequest = response.getCookies().stream()
    .filter(responseCookie -> responseCookie.getName().equals("lastRequest"))
    .findAny()
    .map(responseCookie -> Instant.parse(responseCookie.getValue().orElseThrow()))
    .orElseThrow();

  // Verify expectations vs. actuals
  Assert.assertEquals("Bad status code", expectedCode, actualCode);
  Assert.assertEquals("Reverse failed", expectedBody, actualBody);
  Assert.assertEquals("Largest header failed", expectedLargest, actualLargest);
  Assert.assertTrue("Last request too early", actualLastRequest.isAfter(lastRequestAfter));
}

Integration Testing

First, define something to test:

public class HelloResource {
  // Hypothetical service that performs business logic
  private HelloService helloService;

  public HelloResource(HelloService helloService) {
    this.helloService = helloService;
  }

  // Respond with a 'hello' message, e.g. Hello, Mark
  @GET("/hello")
  public String hello(@QueryParameter String name) {
    return this.helloService.sayHelloTo(name);
  }
}

Perform tests:

Soklet's Simulator is available via Soklet to exercise full request/response flows without binding a port.

@Test
public void basicIntegrationTest() {
  // Just use your app's existing configuration
  SokletConfig config = obtainMySokletConfig();

  // Instead of running in a real HTTP server that listens on a port,
  // a simulator is provided against which you can issue requests
  // and receive responses.
  Soklet.runSimulator(config, (simulator -> {
    // Construct a request
    Request request = Request.withPath(HttpMethod.GET, "/hello")
      .queryParameters(Map.of("name", Set.of("Mark")))
      .build();

    // Perform the request and get a handle to the response
    HttpRequestResult httpRequestResult = simulator.performHttpRequest(request);
    MarshaledResponse marshaledResponse = httpRequestResult.getMarshaledResponse();

    // Verify status code
    Integer expectedCode = 200;
    Integer actualCode = marshaledResponse.getStatusCode();
    Assert.assertEquals("Bad status code", expectedCode, actualCode);

    // Verify response body
    MarshaledResponseBody body = marshaledResponse.getBody().orElse(null);
    if (body instanceof MarshaledResponseBody.Bytes bytesBody) {
      String expectedBody = "Hello, Mark";
      String actualBody = new String(bytesBody.getBytes(), StandardCharsets.UTF_8);
      Assert.assertEquals("Bad response body", expectedBody, actualBody);
    } else {
      Assert.fail("No byte-array-backed response body");
    }
  }));
}

Metrics Collection

Soklet includes a MetricsCollector hook for collecting HTTP and SSE telemetry. Use metrics collectors for low-cardinality counters, gauges, and histograms; use LifecycleObserver for per-request tracing and audit hooks. The default in-memory collector is enabled automatically, but you can replace or disable it:

SokletConfig config = SokletConfig.withHttpServer(
  HttpServer.fromPort(8080)
).metricsCollector(
  MetricsCollector.defaultInstance()
  // or MetricsCollector.disabledInstance()
).build();

Use MetricsCollector.SnapshotTextOptions and MetricsCollector.MetricsFormat to control text output.

You can expose a /metrics endpoint by injecting MetricsCollector into a ResourceMethod:

@GET("/metrics")
public MarshaledResponse getMetrics(@NonNull MetricsCollector metricsCollector) {
  SnapshotTextOptions options = SnapshotTextOptions
    .withMetricsFormat(MetricsFormat.PROMETHEUS)
    .histogramFormat(HistogramFormat.FULL_BUCKETS)
    .includeZeroBuckets(false)
    .build();

  String body = metricsCollector.snapshotText(options).orElse(null);

  if (body == null)
    return MarshaledResponse.fromStatusCode(204);

  return MarshaledResponse.withStatusCode(200)
    .headers(Map.of("Content-Type", Set.of("text/plain; charset=UTF-8")))
    .body(body.getBytes(StandardCharsets.UTF_8))
    .build();
}

Servlet Integration

Optional support is available for both legacy javax.servlet and current jakarta.servlet specifications. Just add the appropriate JAR to your project and you're good to go.

The Soklet website has in-depth Servlet integration documentation.

Learning More

Please refer to the official Soklet website https://www.soklet.com for detailed documentation.

Credits

Soklet stands on the shoulders of giants. Internally, it embeds code from the following OSS projects:

from github.com/soklet/soklet

Installing Soklet

This server has no published package — it is built from source. Open the repository and follow its README.

▸ github.com/soklet/soklet

FAQ

Is Soklet MCP free?

Yes, Soklet MCP is free — one-click install via Unyly at no cost.

Does Soklet need an API key?

No, Soklet runs without API keys or environment variables.

Is Soklet hosted or self-hosted?

Self-hosted: the server runs locally on your machine via the install command above.

How do I install Soklet in Claude Desktop, Claude Code or Cursor?

Open Soklet on unyly.org, pick your client tab (Claude Desktop, Claude Code, Cursor) and press Install — the config is generated automatically, no JSON editing.

Related MCPs

Compare Soklet with

Not sure what to pick?

Find your stack in 60 seconds

Author?

Embed badge for your README

Browse similar

All development MCPs