Soklet Logo

Model Context Protocol

MCP Endpoint Authoring

An MCP endpoint is an immutable operation catalog at one exact path on a dedicated McpServer. Endpoint paths must already be normalized ASCII raw URI paths: absolute, non-root, without a query, fragment, trailing slash, repeated slashes, whitespace or dot segments. Builders and annotations reject these forms instead of rewriting the declared URL. Percent-encode non-ASCII characters. Clients must use the configured path verbatim, including percent encoding. These are fixed paths and do not contain path parameters.

Every endpoint and independently callable operation declares at least one exact McpProtocolVersion; Soklet does not assume the newest version. An operation's revisions must be a subset of its endpoint's revisions. The version below is the application's implementation version reported to clients, not an MCP protocol revision. The 2025 adapter covers synchronous tools, ordinary prompts, and exact or templated resources; see Compatibility and Scope.

Annotation-driven authoring

Apply @McpServerEndpoint to a class and operation annotations to its methods:

@McpServerEndpoint(
  path = "/inventory/mcp",
  name = "inventory",
  version = "1.0.0",
  title = "Inventory MCP",
  description = "Inventory search and reference data",
  websiteUrl = "https://example.com/inventory",
  instructions = "Prefer inventory.search before reading an item.",
  toolRateLimiterName = "inventory",
  protocolVersions = {McpProtocolVersion.V2026_07_28}
)
public final class InventoryEndpoint {
  @McpTool(name = "inventory.search",
    protocolVersions = {McpProtocolVersion.V2026_07_28})
  public SearchResult search(
      @McpToolArgument(name = "query") String query,
      @McpToolArgument(name = "limit") Optional<Integer> limit) {
    return searchInventory(query, limit.orElse(10));
  }

  @McpPrompt(name = "inventory.summary",
    protocolVersions = {McpProtocolVersion.V2026_07_28})
  public McpPromptOutput summary(
      @McpPromptArgument(name = "sku") String sku) {
    return promptFor(sku);
  }

  @McpResource(uri = "inventory://items/{sku}", name = "item",
    protocolVersions = {McpProtocolVersion.V2026_07_28})
  public McpResourceOutput item(
      @McpResourceUriParameter(name = "sku") String sku) {
    return resourceFor(sku);
  }
}

The endpoint annotation exposes these elements:

ElementContract
pathRequired exact listener path
name, versionRequired implementation identity advertised to clients
protocolVersionsRequired nonempty set of exact MCP wire revisions served at this URL
sessionProtocolVersionsEmpty by default; an explicit subset containing only served 2025-06-18 and/or 2025-11-25 revisions requires server session configuration
subscriptionProtocolVersionsModern subscriptions/listen, or matching session-enabled 2025 revisions with an HTTP controller and effective GET notification sources; legacy URI grants require independent subscription authorization
title, description, websiteUrlOptional implementation metadata
instructionsOptional client-facing endpoint guidance
toolRateLimiterNameOptional name from the server's McpRateLimiterRegistry
resourceListCacheTimeToLiveInMilliseconds, resourceListCacheScopeDefault cache hint for custom or static resource pages
resourceTemplateListCacheTimeToLiveInMilliseconds, resourceTemplateListCacheScopeCache hint for the template catalog

The operation annotations are @McpTool, @McpPrompt, @McpResource, and @McpResourceList. Parameter annotations are @McpToolArgument, @McpPromptArgument, @McpResourceUriParameter, and @McpHeader. @McpMayRequestInput declares a client request that an operation may make during a multi-round interaction.

Complete annotation elements

Empty strings mean “not supplied” unless the element is required.

Annotation elements use primitive boolean/long values where Java annotation syntax requires them. The equivalent ordinary public APIs use boxed scalar types.

Compile-time descriptors

SokletProcessor validates annotated declarations and generates immutable descriptors during compilation. Runtime discovery loads those descriptors; it never reflects over methods to build an endpoint catalog.

Use the explicit Maven or Gradle processor configuration, including -parameters. Gradle requires annotationProcessor separately from implementation; modern javac also requires explicit processing selection. For test-authored endpoints, configure Gradle's testAnnotationProcessor too. Successful compilation without a generated endpoint descriptor is not a successful annotation-driven installation.

  • Preserve parameter names during compilation when an annotation does not supply an explicit published name.
  • Preserve generated endpoint provider classes and concatenate every input META-INF/soklet/mcp-endpoint-descriptor-providers index when shading an artifact. Keeping only one module's index silently omits endpoints from other modules.
  • A named Java module must open or export the endpoint package to Soklet.
  • A package containing a non-public record used for runtime conversion must be open to Soklet.
  • Endpoint instances come from an InstanceProvider when an operation is invoked. Discovery and catalog listing do not acquire an instance, and Soklet neither caches nor closes instances returned by the provider.

For Maven Shade, add these transformers inside the plugin's <configuration><transformers> element:

<transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
  <resource>META-INF/soklet/resource-method-lookup-table</resource>
</transformer>
<transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
  <resource>META-INF/soklet/mcp-endpoint-descriptor-providers</resource>
</transformer>

For Gradle Shadow, append both indexes:

tasks.named('shadowJar') {
  append 'META-INF/soklet/resource-method-lookup-table'
  append 'META-INF/soklet/mcp-endpoint-descriptor-providers'
}

Other packaging tools need the same newline-delimited concatenation for both exact resource paths. Merging only META-INF/services does not merge Soklet's indexes. Verify that the packaged index contains every expected provider and that the packaged application exposes every configured endpoint. See the Maven Shade resource transformer documentation.

Select generated endpoints explicitly and deterministically:

McpEndpointRegistry registry = McpEndpointRegistry.fromClasses(
  InventoryEndpoint.class,
  SupportEndpoint.class
);

fromClasspathIntrospection() loads all generated endpoints visible through the context class loader in binary-name order. Prefer fromClasses(...) when the application wants an explicit allowlist and order.

Programmatic endpoints

Start with McpEndpoint.withPath(...), supply an McpImplementation and a nonempty revision set, add registrations for those revisions, then build:

McpImplementation implementation = McpImplementation
  .withNameAndVersion("inventory", "1.0.0")
  .title("Inventory MCP")
  .description("Inventory search and reference data")
  .websiteUrl(URI.create("https://example.com/inventory"))
  .build();

McpEndpoint endpoint = McpEndpoint.withPath(
    "/inventory/mcp", implementation,
    Set.of(McpProtocolVersion.V2026_07_28))
  .instructions("Prefer inventory.search before reading an item.")
  .toolRegistrations(List.of(searchTool))
  .promptRegistrations(List.of(summaryPrompt))
  .resourceRegistrations(List.of(itemResource))
  .resourceListHandler(resourceListHandler,
    Set.of(McpProtocolVersion.V2026_07_28))
  .resourceListCachePolicy(McpCachePolicy.privateNoCacheInstance())
  .resourceTemplateListCachePolicy(
    McpCachePolicy.privateNoCacheInstance())
  .toolRateLimiterName("inventory")
  .build();

McpEndpointRegistry registry = McpEndpointRegistry
  .fromEndpoints(List.of(endpoint));

McpEndpoint.Builder accepts singular and collection forms for tools, prompts, and resources. It also supports:

The built endpoint exposes immutable getters for every value. An endpoint with no operations is valid and advertises no optional operation capabilities.

2025 session configuration

Sessions require two explicit settings. Supply McpSessionConfig through McpServer.Builder.sessionConfig(...), then choose an endpoint subset through McpEndpoint.Builder.sessionProtocolVersions(...) or @McpServerEndpoint(sessionProtocolVersions = ...). The subset may contain only served 2025-06-18 and 2025-11-25 revisions. Empty means disabled; 2026-07-28 remains stateless even at a mixed URL. Missing required server configuration and unused server configuration fail construction.

Create the value with McpSessionConfig.withOwnerKeyResolver(sessionOwnerKeyResolver).build(). The required McpSessionOwnerKeyResolver maps each freshly admitted identity to a stable opaque owner key. Include issuer, tenant, and subject distinctions as needed; a principal or rate-limit partition is not necessarily an owner key. The callback must be local, nonblocking, thread-safe, nonblank, and at most 256 UTF-8 bytes. Failure or timeout fails closed. Anonymous sessions are disabled by default; anonymousSessionsAllowed(true) explicitly opts in. A constant anonymous key shares its owner quota, and changing the key on every initialization must not bypass limits.

The provisional defaults are 256 sessions per server, 16 per owner, 24 hours of actual quiescence, seven days absolute lifetime, and 64 KiB of remembered initialization metadata (also bounded by 4,096 nodes and the existing JSON limits). McpSessionConfig.Builder offers positive limits and durations; null tuning arguments restore defaults. Runtime correlation has separate limits of 32 active calls per session and 256 UTF-8 bytes per retained request ID/progress token; anonymous sessions share a 64-session global sub-budget. Persistent session metadata, GET evidence, and historical subscribe requests have separate byte bounds from transient initialization/POST evidence. The persistent session, owner, and global bounds have floors of 1 MiB, 2 MiB, and 16 MiB and also scale with the header ceiling. For transient requests, let R be the configured maximum body bytes plus maximum header bytes plus 16,384 bytes of accounting allowance: the ceilings are 2R per session, 4R per owner, and 32R globally. Transient memory exhaustion returns HTTP 503 and recovers on physical request exit. A valid large call is therefore not rejected solely by the persistent session ceiling. A retained subscribe request is atomically promoted into persistent accounting without dropping its body or credentials. Failed promotion changes no grant or accounting. A full persistent budget leaves the transient and separately reserved control budgets available for cleanup. Logical cancellation or retirement does not release either kind of evidence before physical cleanup finishes. These are ceilings, not memory reservations or bounds on application-retained object graphs. The suitability of these defaults for production and automatic host recovery remain unverified.

Capacity reclamation replaces only the initializing owner's quiescent sessions. There is no global fair share: 16 owners at the default 16-session cap fill all 256 slots, and new owners receive 503 until slots are released or abandoned sessions reach the 24-hour idle limit. Existing owners can keep recycling their own slots. Anonymous sessions, when enabled, also share a 64-session ceiling. Choose the global count, owner count and idle retention together. For example:

McpSessionConfig sessionConfig = McpSessionConfig
    .withOwnerKeyResolver(sessionOwnerKeyResolver)
    .maximumSessions(1024)
    .maximumSessionsPerOwner(4)
    .maximumSessionIdleDuration(Duration.ofMinutes(15))
    .build();

These bounds permit four sessions per owner and 1,024 globally; abandoned clients expire after 15 minutes of actual quiescence. They still allow 256 owners at their caps to fill the global count, and separate byte/anonymous limits apply. Active clients prevent idle expiry. Owner capacity uses 429, global pressure uses 503, and session capacity failures do not invent a Retry-After value because expiry or physical exit determines recovery. Application rate-limit decisions retain their supplied retry hints.

Physically active initialization, POST and GET work prevents idle expiry. Background URI authorization renewal does not count as client activity, so an abandoned session can expire or be evicted for a new initialization by the same owner while renewal is unfinished. Retirement immediately fences its grants; unfinished callbacks retain their evidence and physical accounting until they exit. Renewal completion and transport keep-alives do not refresh the client's idle lifetime.

Successful initialization publishes a framework-minted opaque ID. Later POSTs require that ID and fresh admission before owner/path/revision/generation binding. Remembered client metadata does not carry permission across requests. Sessions are node-local: route initialization and subsequent requests to the same node through affinity learned from the initial response or an equivalent routing policy. Hashing a newly minted ID cannot route initialization. Restart or node loss yields neutral 404; provide manual reconnection because automatic host recovery remains unverified. There is no shared session backend, event history, replay, or GET result recovery. An optional HTTP admission controller enables the leased GET/DELETE paths below; selected legacy subscriptions also enable session-owned URI grants and resource/catalog invalidations. GET admission itself grants no URI permission.

See Compatibility and Scope and active-request cancelation for current framing and terminal behavior.

Verified control capacity

Small, valid session ping, resources/unsubscribe, notifications/initialized, and notifications/cancelled bodies of at most 16 KiB use an independent framework quota after fresh admission and owner/session verification. They bypass the ordinary request limiter and do not need an ordinary handler slot. Framing, protocol selection, request-ID bounds and active-ID collisions still apply. Malformed, oversized, stateless and unsupported notifications retain ordinary limiting. Admission can still deny these requests.

Each control's retained evidence is capped at maximum header bytes plus 32,768 bytes. The reserved pool permits four physical controls per session, eight per owner, and 64 globally. Cleanup demand also shares four simultaneous reservations and 64 attempts per sliding second per effective authorization partition, with at most 256 retained partitions. GET/URI maintenance has a separate demand budget. Full control capacity returns HTTP 503. Logical cancellation does not free physical reservations before actual work exits.

DELETE transport admission and owner resolution use the independent bounded policy dispatcher. A full tool handler queue therefore cannot prevent verified termination. Application authorization must still finish within its deadline; blocked or rejected policy work fails closed.

HTTP transport admission configuration

The optional McpSessionTransportAdmissionController authorizes real GET/DELETE requests. It receives the original HTTP request, endpoint, exact revision, offered notification families, reauthorization flag, and queue-inclusive callback deadline through McpSessionTransportAdmissionContext. Its cancellation features are available; progress and task creation are unavailable. Every accepted McpSessionTransportAdmissionDecision needs a fresh identity, explicit expiry, and an allowed subset of the offered families. DELETE requires an empty family set. There is no expiry-free acceptance overload.

The application-owned helpers below perform current authorization, select a safe ordinary HTTP rejection when denied, and derive the stable session owner key. authorizeTransport must also honor cancellation and choose an empty family set for DELETE. An explicit authentication challenge is appropriate only when your policy chooses it.

McpSessionTransportAdmissionController transportAdmission =
    (context, invocationFeatures) -> {
      AuthorizedTransport grant = authorizeTransport(context, invocationFeatures);
      if (grant == null)
        return McpSessionTransportAdmissionDecision.rejected(transportDenial());
      return McpSessionTransportAdmissionDecision.accepted(
          grant.identity(), grant.validUntil(), grant.notificationTypes());
    };

McpSessionConfig sessions = McpSessionConfig
    .withOwnerKeyResolver(identity -> stableOwnerKey(identity))
    .transportAdmissionController(transportAdmission)
    .build();

Supply sessions to the server and explicitly select the endpoint's session revisions as described above. Null clears the controller. Controller configuration alone does not offer GET on every session endpoint: GET also needs selected legacy subscription revisions and effective notification families. Legacy subscription revisions must also be enabled for sessions. RESOURCE_UPDATED requires the existing subscription authorizer; unsupported legacy Tasks cannot supply an effective source. URI grants use independent real POST subscribe authorization and bounded renewal; this HTTP configuration alone grants no URI permission or host interoperability. See legacy URI grants and invalidations.

GET and DELETE require an empty body and a live owner/path/revision-bound session ID. Send the selected MCP-Protocol-Version header; it may be omitted only when the endpoint serves exactly one overall revision and that revision is legacy. A session ID alone does not select a revision on multi-version endpoints. DELETE needs no subscription families and returns empty 204 after a fresh still-valid acceptance retires the session. GET also requires Accept: text/event-stream; it may open before initialization acknowledgement and carries keepalives plus currently authorized URI/catalog invalidations. A lease is clipped by application expiry, maximum authorization duration, fixed maximumSubscriptionDuration, and session lifetime. Renewal reuses original GET credentials and cannot extend the total lifetime or revive a retired stream.

At most two logical GETs share one modern/legacy partition registration per session, fixed to the first selected partition. Capacity returns 503 and preserves existing live streams. Request evidence remains charged through physical cleanup after replacement; active GET leases prevent quiescent reclamation. Reconciliation fences prior GET authorization before bounded reevaluation. Modern and session-disabled views retain 405. Host-specific GET/DELETE, authorization renewal, and recovery behavior remain unverified.

Acknowledged abandoned sessions require at least 30 seconds without client activity or physically active work before the same owner may reclaim one. Rapid reconnects without DELETE can exhaust the default 16 sessions per owner and return 429 until reclamation, explicit deletion or expiry. Other owners' sessions are never evicted.

Endpoint registry contracts

McpEndpointRegistry is an immutable ordered collection of endpoints:

Factory or methodEffect
fromClasses(...)Loads selected generated descriptors in supplied order
fromClasspathIntrospection()Loads all visible generated descriptors in binary-name order
fromEndpoints(Collection)Uses programmatically constructed endpoints in collection order
withEndpoint(McpEndpoint)Returns a registry with one programmatic endpoint appended
withSubscriptionConfig(Class, McpSubscriptionConfig)Adds subscription configuration to the generated endpoint belonging to that exact annotated class
withSkillRegistrations(Class, List)Replaces the generated endpoint's standalone Skills list while preserving its existing handlers, settings, and class provenance
getEndpoints()Returns the immutable deterministic endpoint list

Every registry contains at least one endpoint. Two endpoints may not declare the same exact path.

Catalog and routing rules

  • Endpoint selection is an exact raw-path match; requests are not normalized or redirected.
  • Tool and prompt names must be unique within one endpoint.
  • Exact resource URIs must be unique within one endpoint, as must resource-template strings.
  • Potentially overlapping resource templates are rejected during annotation processing or programmatic endpoint/server construction so a read never depends on registration-order ambiguity.
  • The same operation name may appear on different endpoint paths without leaking between them.
  • Programmatic endpoints preserve the supplied order of static registrations. The annotation processor registers static tools and prompts in name order, and static resources and resource templates in URI order.
  • Static 2026-07-28 catalog lists use registration order; static 2025 catalog views use canonical key order. Application-owned @McpResourceList pages preserve the handler's supplied descriptor order.
  • Exact resources are considered before templates during a read.
  • Endpoint catalogs and capabilities remain endpoint-local, while handler slots, the admitted queue, and server lifecycle are shared across the server.

Annotated handler parameters

An annotated operation may receive its declared business arguments plus supported framework parameters such as McpRequestContext, McpInvocationFeatures, a CancelationToken, and an optional McpProgressReporter. Soklet supplies these framework values; they are not part of the published argument schema.

Resource URI template variables are exact String values selected with @McpResourceUriParameter. They do not use Soklet's ordinary HTTP ValueConverterRegistry. Convert and validate domain-specific URI values in the handler.

Continue with Tools and Schemas, Prompts and Resources, Skills, Apps, or the exhaustive Public API Map.

Previous
MCP Quickstart