Model Context Protocol
MCP Security and Policy
Soklet validates protocol shape and enforces bounded transport behavior, but the application remains the authority for authentication, authorization, business policy, and the meaning of client-visible data. The public hooks make those boundaries explicit.
For a complete example with token verification, scope checks, Bearer challenges, and protected resource metadata, see MCP OAuth Integration.
Admission and the anonymous default
MCP POST bodies must be uncompressed. An absent Content-Encoding or an identity-only coding list is accepted. Other codings, including gzip and br, receive HTTP 415 before JSON parsing or application admission. Host and Origin policy checks still take precedence.
Every McpServer uses one thread-safe McpAdmissionController for requests and notifications. If none is configured, the server accepts requests anonymously and emits a startup diagnostic; it does not refuse to start. Passing null restores that anonymous default. Production applications that require authentication or authorization must supply a controller that enforces those policies:
McpAdmissionController admissionController = context -> {
Credentials credentials = authenticate(context.getRequest());
if (credentials == null) {
BearerAuthenticationChallenge challenge =
BearerAuthenticationChallenge.withResourceMetadataUri(
URI.create("https://api.example.test/"
+ ".well-known/oauth-protected-resource/mcp"))
.requiredScopes(List.of(
"mcp:tools:call", "mcp:tools:call:weather.current"))
.build();
return McpAdmissionDecision.rejected(
McpAdmissionRejection.withBearerAuthenticationChallengeAndError(
challenge,
McpJsonRpcError.fromApplication(
1001,
"Authentication required"
))
.build()
);
}
McpAdmissionIdentity identity = McpAdmissionIdentity
.withRateLimitPartitionKey(credentials.accountId())
.authorizationPartitionKey(credentials.tenantId())
.principal(credentials.principal())
.applicationContext(credentials.permissions())
.build();
return McpAdmissionDecision.accepted(identity);
};
McpAdmissionController admissionController = context -> {
Credentials credentials = authenticate(context.getRequest());
if (credentials == null) {
BearerAuthenticationChallenge challenge =
BearerAuthenticationChallenge.withResourceMetadataUri(
URI.create("https://api.example.test/"
+ ".well-known/oauth-protected-resource/mcp"))
.requiredScopes(List.of(
"mcp:tools:call", "mcp:tools:call:weather.current"))
.build();
return McpAdmissionDecision.rejected(
McpAdmissionRejection.withBearerAuthenticationChallengeAndError(
challenge,
McpJsonRpcError.fromApplication(
1001,
"Authentication required"
))
.build()
);
}
McpAdmissionIdentity identity = McpAdmissionIdentity
.withRateLimitPartitionKey(credentials.accountId())
.authorizationPartitionKey(credentials.tenantId())
.principal(credentials.principal())
.applicationContext(credentials.permissions())
.build();
return McpAdmissionDecision.accepted(identity);
};
The exact McpJsonRpcError factory surface is listed in the Public API Map; choose only application-reserved error codes. Soklet prevents applications from spoofing framework-reserved JSON-RPC, MCP, or Soklet error codes.
Soklet validates response-header safety and transports application-authored challenge headers. BearerAuthenticationChallenge constructs and validates the Bearer header value; the application owns the referenced resource metadata, authorization-server selection, and scope semantics. An identifiable request keeps its original JSON-RPC ID and receives the application error with Cache-Control: no-store; a rejected notification keeps the HTTP status and challenge but has no JSON-RPC body or Content-Type.
McpAdmissionRejection.Builder.headers(...) and addHeader(...) validate eagerly and leave previous headers intact if validation fails. Names must be ASCII HTTP tokens, unique ignoring case; lists must be nonempty and values may contain only visible ASCII or horizontal tabs (an empty string is permitted). The fixed profile allows at most 100 fields and 65,536 bytes, counting name length plus value length plus four bytes for each field. Reserved names are Cache-Control, Connection, Content-Encoding, Content-Length, Content-Type, Keep-Alive, Proxy-Authenticate, Proxy-Authorization, Proxy-Connection, TE, Trailer, Transfer-Encoding, Upgrade, Retry-After, Mcp-Session-Id, Last-Event-ID and every Access-Control-* name, ignoring case. Safe headers preserve original spelling and value order in an immutable snapshot; custom names and values do not appear in validation errors.
McpAdmissionContext exposes the original request, selected endpoint, high-level McpOperationType, exact JSON-RPC method, notification flag, optional request ID and operation name, protocol version, informational client data, validated capabilities, and validated MCP trace context. It is deliberately bounded and does not expose parsed operation arguments.
For tools/call, prompts/get and resources/read, application admission runs before target membership, descriptor-dependent prompt arguments, custom mirrored headers, required client capabilities and registration-dependent request-state checks. This applies to all supported protocol revisions. A rejected caller receives its admission response for both known and unknown targets, without invoking an operation handler. getOperationName() is the syntactically validated requested name or URI; its presence does not establish that a target exists. Malformed envelopes, endpoint/version selection, profile metadata and required protocol/method/header-shape checks still fail before admission. Modern and stateless legacy requests also validate cheap operation parameters before admission. Session-enabled 2025 non-initialize requests defer operation-parameter errors through admission, applicable request limiting and session binding, so malformed parameters can leave getOperationName() empty for a normally named operation. Handle that optional value without requiring it. With a caller-aware catalog access policy, tools/call defers missing or mismatched Mcp-Name agreement until after admission, catalog access policy and the tool limiter; other standard name comparisons stay earlier. Caller-aware catalog access policy continues to protect direct tool and prompt access after admission.
Use McpAdmissionContext.getOperationType() when admission policy branches on a recognized operation. getJsonRpcMethod() preserves the exact validated wire value for diagnostics and extension handling; an unrecognized, future, or extension method has operation type OTHER without losing that string.
McpAdmissionDecision.accepted() is the direct anonymous-identity decision. McpAdmissionController.acceptAllInstance() supplies that decision for every request; use it only for an intentionally public endpoint, not as a placeholder authentication mechanism.
Accepted and Rejected remain public nested types so wrappers and tests can use typed pattern matching, but they are final factory-owned values with private constructors. Create them only through McpAdmissionDecision.accepted(...) or rejected(...); an Accepted exposes getIdentity() and a Rejected exposes getRejection().
Admission identity
McpAdmissionIdentity has four distinct fields:
| Field | Purpose |
|---|---|
| Rate-limit partition key | Stable opaque key for request/tool quotas |
| Authorization partition key | Stable opaque boundary for protected state and authorization-sensitive behavior |
| Principal | Optional application-owned authenticated principal |
| Application context | Optional application-owned data propagated to later MCP hooks |
Partition keys must be nonblank and at most 256 UTF-8 bytes. A principal requires an authorization partition key. The carrier is shallowly immutable; the application owns thread safety for objects stored as principal or context.
Client names, versions, capabilities, request IDs, trace metadata, forwarded headers, and operation arguments are not authenticated identity. Derive identity only from a trusted authentication path.
IP-derived partitions and trusted proxies
Soklet does not automatically turn Forwarded, X-Forwarded-For, the raw socket peer, or MCP clientInfo into a rate-limit identity. Forwarding headers remain ordinary request headers until application admission code explicitly resolves them, and the built-in limiter partitions only on the McpAdmissionIdentity returned by that code. A custom limiter can inspect the raw Request through McpRateLimitContext.getRequest() and must likewise treat forwarding headers and self-reported MCP metadata as untrusted unless application policy deliberately resolves them under the same proxy boundary. In particular, admission code must never build a partition from McpAdmissionContext.getClientInfo(); its name and version are supplied by the requester.
If an intentionally anonymous service uses client IP as one input to its quota policy, derive the partition in the admission controller with an explicit trust policy:
Set<InetAddress> trustedProxyAddresses = Set.of(
InetAddress.getByName("203.0.113.10")
);
McpAdmissionController admissionController = context -> {
InetAddress clientIp = EffectiveClientIpResolver
.withRequest(
context.getRequest(),
EffectiveOriginResolver.TrustPolicy.TRUST_PROXY_ALLOWLIST
)
.trustedProxyAddresses(trustedProxyAddresses)
.resolve()
.orElseThrow(() -> new IllegalStateException("Client IP unavailable"));
return McpAdmissionDecision.accepted(
McpAdmissionIdentity
.withRateLimitPartitionKey("ip:" + clientIp.getHostAddress())
.build()
);
};
Set<InetAddress> trustedProxyAddresses = Set.of(
InetAddress.getByName("203.0.113.10")
);
McpAdmissionController admissionController = context -> {
InetAddress clientIp = EffectiveClientIpResolver
.withRequest(
context.getRequest(),
EffectiveOriginResolver.TrustPolicy.TRUST_PROXY_ALLOWLIST
)
.trustedProxyAddresses(trustedProxyAddresses)
.resolve()
.orElseThrow(() -> new IllegalStateException("Client IP unavailable"));
return McpAdmissionDecision.accepted(
McpAdmissionIdentity
.withRateLimitPartitionKey("ip:" + clientIp.getHostAddress())
.build()
);
};
The allowlist must contain every possible physical socket peer and every trusted proxy-hop address expected in the forwarded chain, never end-client addresses. With TRUST_PROXY_ALLOWLIST, an untrusted physical peer makes forwarded values inert and the resolver falls back to that raw peer when available. It also applies the trust predicate while walking the forwarded chain from right to left, so a spoofed leftmost value does not automatically win. The trusted proxy or network edge must strip or overwrite both Forwarded and X-Forwarded-For; usable Forwarded: for= values take precedence over X-Forwarded-For. Use TRUST_NONE when Soklet receives direct traffic. Do not use TRUST_ALL when an untrusted client can reach the listener, and do not treat a trusted-proxy allowlist as a substitute for network controls that prevent clients from bypassing that proxy. See Effective Client IP Resolution for parsing and chain-walk details.
Client implementation names and versions are required strings, and blank values are valid client metadata. McpImplementation.withNameAndVersion(name, version) preserves them exactly, including when a legacy session remembers them. Missing or non-string required fields remain protocol errors. Configured server information still requires nonblank names and versions in programmatic and annotation endpoint declarations.
Rate limiting
A server may configure a request limiter, invoked once for admitted requests and notifications except the verified legacy control subset described below. This limiter is required when any endpoint configures a Completion handler; server construction fails without it. Completion uses this one request charge, not the tool limiter. Select meaningful caller/tenant budgets, optionally branching on McpOperationType.COMPLETION_COMPLETE; the required callback does not itself establish a restrictive or fleet-wide policy. Tool rate limiting is optional. A configured tool limiter is resolved in this order:
- Tool override
- Endpoint override
- Server fallback
If no limiter resolves, the tool has no tool rate quota. Existing transport, queue, concurrency, and execution bounds still apply. An endpoint or tool override can be used without a server fallback. When both request and tool limiters are configured, they make separate acquisitions. Successful charges are never refunded after a later denial, exception, cancelation, timeout, or response-write failure.
McpRateLimiter.acquire(McpRateLimitContext) returns McpRateLimitDecision.allowed() or denied(retryAfter). The context contains the request, endpoint, accepted identity, REQUEST or TOOL target, high-level operation type, exact JSON-RPC method, and optional operation name. Use McpRateLimitContext.getOperationType() for semantic policy and getJsonRpcMethod() only when the exact wire value matters. A null decision or exception fails closed. Denial maps to HTTP 429 and MCP code -31999.
The Allowed and Denied nested variants follow the same factory-owned pattern: they are public for typed matching, not direct construction. Denied.getRetryAfter() returns the validated nonnegative delay.
Named overrides resolve through McpRateLimiterRegistry while the server is built. This lets annotations refer to an application-provided limiter without hiding resolution errors until traffic arrives.
For explicitly enabled 2025 sessions, small, valid owner-verified ping, unsubscribe, initialized and cancellation messages use bounded framework control quotas and bypass the ordinary request limiter. Admission, authentication, owner/session checks and request-ID collision rules still run. This keeps cleanup available when ordinary quota is exhausted. Malformed/oversized controls, stateless traffic, and unsupported notifications keep ordinary limiting. HTTP GET/DELETE use fresh transport admission rather than the RPC limiter.
Built-in token bucket
McpRateLimiter limiter = McpRateLimiter.fromInMemoryTokenBucket(
McpTokenBucketConfig.withCapacity(20L)
.refillTokens(60L)
.refillInterval(Duration.ofMinutes(1))
.build()
);
McpRateLimiter limiter = McpRateLimiter.fromInMemoryTokenBucket(
McpTokenBucketConfig.withCapacity(20L)
.refillTokens(60L)
.refillInterval(Duration.ofMinutes(1))
.build()
);
Choose all three quota values explicitly: capacity, refill quantity, and refill interval. The example permits an initial burst of 20 calls and continuously replenishes tokens at 60 per minute; it is an illustrative policy, not a default or a hard cap of 60 calls in every minute. Missing refill settings fail at build(), and the setters reject null. State is partitioned by endpoint path, admitted rate-limit key, and target, with a finite retained-partition bound. The limiter is local to one JVM; use a distributed application implementation for fleet-wide limits.
Host policy
Host validation runs before protocol parsing or application side effects. A loopback bind seeds automatic aliases; a non-loopback bind requires an explicit deployment hostname or IP literal. allowedHosts(Set<String>) adds deployment-specific hostname or IP-literal values; it is a hostname allowlist, not a URL or CIDR list. Hostname labels accept ASCII letters, digits and hyphens. Underscores, including in Docker service names, are rejected; configure a valid deployment alias instead.
Explicit names accept a syntactically valid public port or an omitted port, including behind a TLS proxy whose public port differs from Soklet's listener port. Automatic loopback aliases retain the listener-port check. Forwarded headers cannot authorize an unlisted hostname. Invalid Host syntax and duplicate fields fail with 400; a well-formed unauthorized authority fails with 421. Origin checks remain independent.
The listener binds to 127.0.0.1 by default. Binding to a remote interface should be paired with an explicit Host allowlist, trusted proxy configuration outside this API, authentication, authorization, rate limiting, and TLS termination. Soklet's MCP listener does not terminate TLS.
Origin and CORS
Host policy and browser Origin policy are independent:
- An absent
Originis allowed by default.McpAbsentOriginPolicy.REQUIRE_ORIGINrejects it. - A present Origin must be approved by the shared
CorsAuthorizer. - Omitting the authorizer uses reject-all behavior for present origins and emits a fixed startup diagnostic.
- Supplying
CorsAuthorizer.rejectAllInstance()makes that choice explicit.
MCP uses the transport-neutral CorsAuthorizer.authorizePreflight(request, corsPreflight, availableHttpMethods) overload. Its default implementation rejects, so a custom authorizer must override that overload as well as ordinary authorization and must be safe for concurrent calls. Deliberate denial is 403; null, throwing, or invalid results fail closed without allow headers.
Successful CORS responses may expose WWW-Authenticate. The allowed request-header surface includes modern protocol/name headers, registered mirrored headers, and Authorization. A path explicitly enabling 2025 sessions also permits and exposes the framework-owned Mcp-Session-Id; session-disabled paths continue to reject that header in preflight. A URL with configured legacy GET permits bounded Last-Event-ID input, which is ignored and supplies no replay position; other paths reject it in preflight. Neither name may be emitted by application-authored admission headers. Preflight without a revision selector uses the URL's configured facility union; actual GET/DELETE dispatch and Allow use the explicit selected revision. Independent raw response-head goldens pin exact ordering and multiplicity for authorized preflight, challenged requests, and empty Origin rejection. Explicit preflights requesting the ignored event header where GET is unavailable, or session headers on a session-disabled path, fail closed without allow headers or admission/downstream work. See CORS for the shared API.
Mirrored tool headers
@McpHeader(name = "Tenant") on an eligible typed tool argument publishes x-mcp-header and requires Mcp-Param-Tenant to equal the already-parsed JSON property. It never supplies a missing or null property; the matching header must also be absent in that case. Both values remain untrusted. Mirrored tools are restricted to 2026-07-28; endpoint construction rejects a mirrored tool selecting either 2025 revision.
Integer types such as long, Long, and BigInteger can register without narrowing their schema range. Each mirrored request value must be between -9007199254740991 and 9007199254740991, inclusive, with canonical decimal header spelling. An unsafe value or mismatch fails after admission and before handler entry with fixed HTTP 400 / JSON-RPC -32020 "Header mismatch"; the response includes no submitted header name or value. See Tools and Schemas for spelling rules.
Unknown Mcp-Param-* headers are ignored by default. McpUnknownMirroredHeaderPolicy.REJECT_REQUESTS changes that to HTTP 400 / MCP -31998. Optional name-bearing diagnostics are independently controlled, bounded, and disabled by default because an attacker controls the header name. Soklet never exposes its value through that diagnostic.
Handler interceptor
One server-level McpHandlerInterceptor wraps every application-owned tool, prompt, resource-read, and custom resource-list handler. It does not wrap framework discovery or static catalogs.
McpHandlerInterceptor interceptor = (requestContext, invocationFeatures, continuation) -> {
authorizeOperation(requestContext);
return continuation.proceed();
};
McpHandlerInterceptor interceptor = (requestContext, invocationFeatures, continuation) -> {
authorizeOperation(requestContext);
return continuation.proceed();
};
The hook runs after admission, rate limiting, queue admission, and handler-slot acquisition, but before full application-input conversion and the handler. The continuation is synchronous, one-shot, and bound to the invoking thread. An interceptor may transform a result or return a method-compatible short circuit; the result still undergoes all applicable validation and sanitization.
Applications needing a chain compose it behind this one hook and own the order. A null result or exception fails closed without reflecting exception-derived data.
Modern subscription reauthorization
For 2026-07-28 subscriptions, reconciliation fences delivery while current authority is checked. Resource-list and accepted-URI invalidations received behind that fence retain bounded dirty markers. Successful reauthorization sends catch-up hints under the current grant; denial, expiry, and termination discard them. A renewal replacing the registration does not lose an otherwise authorized hint.
Queued resource frames check the current authorization generation, fence, and lease before socket writes. Revoked unwritten frames are removed; a partially written revoked frame closes before further bytes. Catch-up retains at most one list marker and one per accepted URI (up to 256); it provides no event history or client receipt guarantee. Authorization callback timeouts begin when maintenance runs, while the previous lease and total stream lifetime keep running during the queue wait.
An explicit denial during reconciliation uses SUBSCRIPTION_AUTHORIZATION_DENIED and the reconciliation maintenance outcome DENIED. A check that throws, times out, or cannot be scheduled uses the corresponding failure classification. An established writable modern listen receives its tagged completion before closing; that response does not authorize any further notification or reflect exception details. See listen-stream behavior.
Legacy notification authority
On explicitly subscription-enabled 2025 session views, authorize real resources/subscribe and resources/unsubscribe operations during RPC admission. A syntactically valid subscribe exposes its URI before a grant is created, so admission can issue a scope challenge; a valid unsubscribe exposes its URI as the operation name with an empty selection. Malformed operation parameters can reach admission and applicable request limiting with an empty operation name on session-enabled 2025 views, before session binding and the parameter error. Handle that optional value without requiring its presence. A readable route is not notification permission. The existing subscription authorizer independently grants each URI lease using the real subscribe request and must check current authority during renewal. Historical credentials/context remain retained evidence through physical callback exit; keep application-owned principal/context graphs small and safe.
GET admission never adds URI permission or replaces a URI grant's historical credentials. Refresh stream credentials with a newly admitted GET and URI credentials with a fresh or duplicate subscribe. Duplicate subscribe replaces evidence without resetting total lifetime; unsubscribe/reconciliation fence establishing and active grants. Deferred fence cancellation targets older-generation checks, preserving a fresh check under the fenced generation. Queued legacy hints check current GET/grant generations and expiry before every socket write. Revoked unwritten frames are purged, while a partially written revoked frame closes before further bytes. Bounded dirty bits survive GET gaps without event history or receipt guarantees. Grant/evidence/output/maintenance caps are independent; no URI, owner key, or session ID is added to built-in metric labels. Host-specific credential refresh and reconnect behavior remain unverified.
Tool result sanitizer
McpToolResultSanitizer receives request context, tool name, raw arguments, and the complete result, including its metadata. It runs after handler/interceptor processing and enough structural validation to identify a tool payload, but before remaining validation, output-schema checking, structured-content mirroring, and serialization.
McpToolResultSanitizer sanitizer = (request, name, arguments, completeResult) ->
completeResult.toBuilder()
.payload(redactToolOutput((McpToolOutput) completeResult.getPayload()))
.metadata(redactResultMetadata(completeResult.getMetadata()))
.build();
McpToolResultSanitizer sanitizer = (request, name, arguments, completeResult) ->
completeResult.toBuilder()
.payload(redactToolOutput((McpToolOutput) completeResult.getPayload()))
.metadata(redactResultMetadata(completeResult.getMetadata()))
.build();
Soklet validates and writes the returned payload and metadata exclusively. A null result, non-tool payload, or exception fails closed and discards original and partial result data without exposing exception-derived details. The shared nonSanitizingInstance() default preserves the result unchanged; normal validation and output limits still apply. Implementations must be concurrent-safe and deterministic for equivalent repeated calls, including authorized completed-task reads on different server nodes. input_required, progress notifications, and task-status metadata do not traverse this hook.
Application-owned result metadata can carry UI-directed data, but it is not a secret channel: the host receives it and may log or forward it. Apply caller-aware redaction and never copy confidential metadata into text or structured content as a fallback for clients without Apps support.
Output and error boundaries
Every MCP HTTP response contains exactly one Cache-Control: no-store. Error mapping preserves valid string and integer request IDs and redacts hostile values. Retry-After is supplied for rate-limit denials and exposed to browser code for authorized CORS origins; ordinary mapped errors and session capacity failures do not invent a retry hint. Required-preflight and conditional-result capability failures use their respective -32021 paths; see Compatibility and Scope for validation order and response details.
A nonstream mapped response reserves its terminal outcome before a late pre-body cancelation. Written streamed-error bytes win over concurrent cancelation exactly once; cancelation before streamed-error reservation discards both the terminal response and its provisional protocol-error metric. No progress frame follows a mapped terminal.
Execution bounds
Handlers are synchronous, but the dispatcher bounds active executions, admitted queue depth, and an absolute request deadline. Defaults are 32 active handlers, 128 queued requests, and 60 seconds. A supplied executor does not bypass these limits. Queue exhaustion maps to HTTP 503. A still-queued writable deadline returns that same fixed response without invoking application code; disconnect writes nothing. Promotion, queued deadline, and disconnect elect one queue removal and one observable request outcome, including when disconnect makes an internally reserved deadline response unwritable before transport handoff.
Application-owned policies, handlers, interceptors, limiters, publishers, collectors, and protectors may be invoked concurrently and must honor their documented thread-safety contracts.
Skills authorization
For Skills, admission is followed by a separately configurable access policy. Hiding a Skill from discovery is not the same as denying its files: exact lookups and reads freshly evaluate access, and a shared file may be available through another accessible owner. See Skills access and discovery before publishing caller-specific bundles.
Task authorization
Every tasks/get, tasks/update, and tasks/cancel request traverses protocol validation, admission, and the configured request limiter independently. The application-owned McpTaskManager then performs task authorization again against the current admitted request. Possession of a task ID is not authorization, and a task ID must not be used as a principal or tenant boundary.
Bind each durable task to its endpoint and the application identity dimensions needed by policy. McpTaskRequestContext and McpTaskUpdateContext carry the fresh McpRequestContext, selected endpoint, admitted identity, and task ID into the manager. Perform the binding comparison and read or mutation atomically so authorization cannot race a task move or ownership change.
Unknown and unauthorized IDs must be indistinguishable. McpTaskManager.findTask(...) returns an empty optional for both; mutation methods use the same fixed McpTaskNotFoundException signal. Do not place task IDs, principals, or unbounded operation values in built-in metric labels, and treat task IDs plus the opaque McpTaskOrigin as potentially sensitive data. See MCP Tasks.
Multi-round input policy
Soklet validates MCP elicitation wire structures and client capability declarations. It does not decide whether a requested field is semantically secret, a side effect is authorized, or an out-of-band workflow belongs to the current user.
Use these fail-closed application patterns:
- For form elicitation, refuse secret-bearing field concepts, branch explicitly for accept, decline, cancel, and missing-response re-request, and validate the exact accepted field set and values.
- For URL elicitation, issue only server-owned HTTPS URLs with an opaque state handle. Put no identity, secret, bearer capability, userinfo, query, or fragment in the URL; store the workflow state server-side and bind it to the verified initiating user.
McpInputResponses typed lookup performs conversion only. Response-key correlation, missing-key behavior, elicitation action handling, accepted-content policy, and side-effect authorization remain application-owned. See Input and Request State for the full flow.
Executable application patterns
These examples illustrate application-owned security boundaries:
- application example with rotating handles resumes an explicit rotating handle through a fresh workflow over a shared repository, binds it to the admitted context, rejects crossed or replayed handles without consuming valid state, and keeps identity out of the opaque value. Its prompt half authorizes before server-owned resource access, applies exact business allowlists and reviewed input/reference/output policy, and emits one fixed neutral failure. Its fragment policy is deliberately deployment-specific, not a claim that a universal prompt-injection detector exists.
- filesystem resource example canonicalizes and contains a filesystem path before authorizing and reading it, distinguishes direct-HTTPS from handler-only custom-resource delivery, and authenticates stable snapshot/revision/expiry/offset cursor claims while keeping the principal in HMAC associated context rather than the payload. Every cursor-invalid classification maps to the same no-data
-32602error. - localized catalog and cursor example transfers only an opaque cursor between independently configured nodes, retains one snapshot across a live-catalog change, binds catalog and localization revisions plus locale/expiry/offset, and preserves the same cursor bytes through provider preselection and full handler authentication. Its separately populated repositories and copied application keys model deployment responsibilities; they are not Soklet services.
The application owns deployment-specific file time-of-check/time-of-use controls, shared cursor keys and snapshots, cross-instance portability, and locale/localization-revision binding. The cursor examples illustrate single-process and two-node approaches; Soklet supplies no distributed store or key-management service. See Prompts and Resources and Input and Request State for the full guidance.
Deployment responsibilities
Soklet does not provide an OAuth authorization server, identity provider, TLS terminator, distributed quota service, secret store, or business authorization model. An application can implement authentication and standards-compliant challenges at admission, but full MCP Authorization conformance depends on every applicable deployment obligation, not just this hook.
Multi-node deployments
Modern and session-disabled legacy requests can use round-robin routing when each eligible node has the required application data, authorization policy, protected-state keys, and catalog configuration. Application-owned resource cursors need shared or replicated snapshots and keys. Framework legacy catalog cursors contain navigation state; receiving nodes must have the matching endpoint/revision/catalog fingerprint and negotiated locale.
Session-enabled legacy endpoints require affinity to the initializing node. Route POST, GET, DELETE, and cancellation messages there. Soklet retains session IDs, remembered client metadata, active requests, and resource grants in that node. McpSessionOwnerKeyResolver and McpSessionTransportAdmissionController authorize sessions; they do not replace their storage. There is no shared session backend. A rolling replacement or node loss requires the client to initialize again and resubscribe. Established streams cannot move between nodes, and reconnecting provides no event replay. See Endpoints for session routing and recovery limits.
The existing application hooks can use distributed services:
McpTaskManagercan front durable shared task storage and job infrastructure.McpSubscriptionEventPublisherandMcpTaskEventPublishercan broadcast change events to every eligible node. Competing-consumer delivery would miss streams on other nodes.- Admission, subscription authorization, and catalog-access callbacks can consult shared policy. After a policy change, signal every applicable server through
McpSubscriptionReconciler. Its return confirms local fencing and scheduling, not fleet-wide policy convergence. Localization invalidation similarly needs each node's current snapshot installed before signaling it. McpRateLimitercan enforce fleet-wide request/tool quotas. Built-in session, connection, subscription, and maintenance-capacity bounds remain per server instance.
Localization privacy
When a localizer is configured, raw language preferences, tags, translated text, translation keys, coordinates, and revisions are never framework log fields, exception text, metric labels, or retained sets, and a provider throwable is never forwarded to lifecycle callbacks, simulator completions, or logs. See Localization.

