Model Context Protocol
MCP Observability and Testing
MCP reuses Soklet's existing LifecycleObserver, MetricsCollector, and Simulator hosts. There is no parallel MCP-only observer or collector, and McpHandlerInterceptor is not an observability substitute.
Observation stability boundary
This guide covers Soklet 4.0.0, including subscription-maintenance telemetry. MCP Tasks retains its provisional protocol/API maturity label. See the Public API Map for the Java types and Compatibility and Scope for supported protocol behavior.
Inspecting intentional handler errors
When an application handler throws McpJsonRpcException, McpHandlerContinuation.proceed() throws the original exception object. A McpHandlerInterceptor can inspect its error and rethrow it unchanged:
McpHandlerInterceptor interceptor =
(requestContext, invocationFeatures, continuation) -> {
try {
return continuation.proceed();
} catch (McpJsonRpcException exception) {
McpJsonRpcError error = exception.getError();
// Inspect the application's intentional error here.
throw exception;
}
};
McpHandlerInterceptor interceptor =
(requestContext, invocationFeatures, continuation) -> {
try {
return continuation.proceed();
} catch (McpJsonRpcException exception) {
McpJsonRpcError error = exception.getError();
// Inspect the application's intentional error here.
throw exception;
}
};
Rethrowing the same object preserves its client-visible code, message and data, including revision-specific resource-not-found intent. This works on all three supported revisions for tools, prompts, resource reads, custom resource lists and prompt/resource completion, and for custom Skills lists on the modern revision. An interceptor may instead return a method-compatible recovery result; that result still passes through ordinary validation and, for completed tool results, sanitization and output-schema validation.
An interceptor-created exception, a copy carrying the same error, a wrapper or an exception retained from an earlier invocation fails closed with the fixed internal-error response. The continuation remains synchronous, usable once, on its invoking thread and only during that interception call. Error payloads remain application-owned data; inspecting an error does not make its contents safe to log or add to metric labels.
Lifecycle callbacks
LifecycleObserver provides server callbacks:
willStartMcpServer(McpServer mcpServer)
didStartMcpServer(McpServer mcpServer)
didFailToStartMcpServer(McpServer mcpServer, Throwable throwable)
willStopMcpServer(McpServer mcpServer)
didStopMcpServer(McpServer mcpServer,
ShutdownComponentResult shutdownComponentResult)
willStartMcpServer(McpServer mcpServer)
didStartMcpServer(McpServer mcpServer)
didFailToStartMcpServer(McpServer mcpServer, Throwable throwable)
willStopMcpServer(McpServer mcpServer)
didStopMcpServer(McpServer mcpServer,
ShutdownComponentResult shutdownComponentResult)
It also provides an admitted semantic request pair:
didStartMcpRequestHandling(McpRequestContext requestContext)
didFinishMcpRequestHandling(
McpRequestContext requestContext,
McpRequestOutcome requestOutcome,
McpJsonRpcError jsonRpcError, // nullable
Duration requestDuration,
List<Throwable> throwables
)
didStartMcpRequestHandling(McpRequestContext requestContext)
didFinishMcpRequestHandling(
McpRequestContext requestContext,
McpRequestOutcome requestOutcome,
McpJsonRpcError jsonRpcError, // nullable
Duration requestDuration,
List<Throwable> throwables
)
Start runs after admission and before request/tool rate limiting, queue admission, framework result generation, interception, input conversion, or handler entry. Framework discovery and static catalogs are included.
Finish is captured exactly once for the client-visible terminal outcome and delivered by the observation worker. It is a request-finish callback, not necessarily a handler-exit callback: non-cooperative application code can still be running. Start and finish receive the same immutable context but may run on different threads. Independent callbacks may be concurrent. Observer exceptions are contained and never change the wire result.
McpRequestContext.getStartedAt() returns the wall-clock semantic start for built-in observed contexts, captured before start observers. The elapsed finish duration uses the same semantic origin and excludes finish-delivery queue delay. Derived subscription contexts retain the original listen request's start. Custom and manually constructed contexts inherit an empty optional by default.
Finish observation and its trace/failure logs use a separate bounded worker dispatcher, away from connection event loops and the protocol/handler pools. Workers are bounded daemon platform threads, including on Java21. Capacity is reserved before start; exhaustion omits the entire observation pair and emits a fixed, rate-limited diagnostic. It never rejects protocol or cancellation traffic solely for observation capacity. Every emitted start retains its finish. Metrics and spans derived from an omitted pair omit that request too. Caller-aware policies, handlers and subscription authorization still receive their admitted context and invocation features. If a deadline or cancellation terminates the request during context/start creation, later policy and session state work is skipped. Each finish follows its start and reports the client-visible outcome/duration, independently of callback queue delay. A blocked observer remains physical shutdown work.
Admission, both rate limiters, CORS, start observation and state opening share the protocol pool. protocolExecutorConcurrency and protocolExecutorQueueCapacity configure its 32/128 defaults separately from the handler executor.
When observer behavior branches by operation, use McpRequestContext.getOperationType() and the semantic McpOperationType values. getJsonRpcMethod() remains appropriate for diagnostic output that needs the exact validated wire value.
The Throwable list contains exact observed instances and may carry secrets or application data. Treat it as sensitive and apply an explicit logging/retention policy.
GET/DELETE on explicitly enabled 2025 session views use the existing generic HTTP lifecycle/metrics pair with ServerType.HTTP and no ResourceMethod. The observer receives the original Request and exact Throwables under application retention policy. These HTTP-only paths create no fabricated McpRequestContext, RPC method, RPC request metrics, or request-limiter call. GET lifetimes emit SubscriptionOpened/SubscriptionClosed; RPC RequestStreamOpened/RequestStreamClosed remain tied to RPC streams. Physical GETs contribute to stream/subscription diagnostics, including replaced bodies awaiting cleanup. No public session callback or session-ID/owner metric dimension is added. Legacy subscribe/unsubscribe remain real RPC operations with normal admission, request limiting, and request observation; GET notification delivery adds no fabricated RPC operation. Retained subscribe evidence and ignored authorization callbacks remain physically charged after logical grant retirement.
Generic GET/DELETE finish callbacks run on dedicated bounded workers: at most four, capped by the configured handler concurrency. GET lifetimes have a separate reservation bucket of twice the global session capacity (512 with the defaults), subject to the implementation's aggregate integer cap. DELETE and other transient controls use worker-plus-handler-queue capacity (132 with the defaults); GET can use that transient headroom after its lifetime bucket fills. Soklet reserves capacity before invoking start and retains it through the request lifetime and physical finish callback. If capacity is full or quiescing, it skips both callbacks. Capacity saturation while accepting emits a fixed non-error configuration diagnostic at most once per minute; quiescing skips silently. Keep callbacks prompt: a blocked callback retains capacity and can leave shutdown incomplete. If the transport ends while start is still running, finish runs once after start returns.
Failure Log Events And Redaction
The built-in MCP server emits LogEvent diagnostics for unexpected application-execution, request-observer, semantic-metrics collector and transport failures. These events deliberately leave getThrowable(), getRequest(), getResourceMethod() and getMarshaledResponse() empty. Messages identify the failing operation without exception text or stack traces. The affected event types are:
MCP_REQUEST_PROCESSING_FAILED, once for an unexpected application-execution failure; routine capacity, cancelation, deadline and intentional rejection outcomes do not emit itLIFECYCLE_OBSERVER_DID_START_MCP_REQUEST_HANDLING_FAILEDLIFECYCLE_OBSERVER_DID_FINISH_MCP_REQUEST_HANDLING_FAILEDMETRICS_COLLECTOR_FAILED, when emitted for MCP semantic metricsSERVER_TRANSPORT_FAILURE, when emitted by the built-in MCP transport
HTTP/SSE failure events may attach the original Throwable. An empty attachment on these MCP diagnostics does not mean no exception occurred. Use the event type and fixed message to monitor failures; do not expect a stack trace from this channel.
Typed MCP callback arguments and their exact observed Throwables remain application-owned sensitive objects. Apply your logging and retention policy before exporting them. The same applies to application-created LogEvents, generic HTTP callbacks for 2025 session GET/DELETE, and aggregate lifecycle-transition observer failures, which retain their original Throwable. The MCP log-event boundary does not automatically redact those channels.
Semantic metrics events
MetricsCollector::didRecordMcpMetricsEvent receives immutable McpMetricsEvent transition-specific events. Implementations must be nonblocking and thread-safe; failures are logged and contained. Final semantic records, including ServerStopped, can arrive after Soklet.awaitShutdown() completes or a scoped Soklet.run(config, action) returns, including after application cleanup. Neither lifecycle completion nor cleanup is a semantic-metrics flush barrier; account for pending delivery before closing an exporter.
The built-in MCP server queues at most 4,096 pending semantic metric records. If a collector falls behind and fills that queue, new ordinary records are omitted. ServerStarted and ServerStopped reclaim an ordinary record to preserve owner lifecycle evidence. Records withheld during a short runtime transition count against that same bound. Retained records for one request keep their enqueue order across runtime and application execution; an independent request may deliver while another request's transition is withheld. Counters and gauges can be incomplete after an overflow. Keep collectors fast and send expensive processing to an application-owned bounded worker.
Collector callbacks are serialized after the relevant internal locks are released. Closing a short runtime transition signals the dedicated metrics worker, so a slow collector does not hold the closing protocol or connection thread. Server and Soklet lifecycle deferral preserves start/shutdown ordering through a separate server-wide gate. A blocked progress write does not defer unrelated metrics. Subscription maintenance also signals the metrics worker, so collector delivery does not hold a projection worker or prevent that worker from exiting during shutdown.
The sealed event family currently contains:
- Server and transport:
ServerStarted,ServerStopped,ConnectionAccepted,ConnectionRejected, andTransportFailure. - Processor boundary:
RequestAcceptedandRequestRejected. - Admitted requests:
RequestStartedandRequestFinished. - Handler capacity:
HandlerExecutionStarted,HandlerExecutionFinished,HandlerQueued,HandlerDequeued, andHandlerCapacityRejected. - Streaming:
RequestStreamOpened,RequestStreamClosed,SubscriptionOpened,SubscriptionClosed, andKeepAliveEmitted. - Subscription maintenance:
SubscriptionMaintenance, with fixed work and outcome values for authorization, catalog projection, and reconciliation. - Invocation behavior:
ProgressEmittedandCancelationSignaled. - Protocol policy:
ProtocolErrorandUnknownMirroredHeader.
For one request, a deferred RequestAccepted is delivered before its later retained ProtocolError or RequestRejected, including when processing, timeout, or cancellation runs on another thread. Independent requests can interleave, and the queue bound permits omitted records. Use the request lifecycle/outcome API when you need complete request state.
Each public final variant exposes only dimensions meaningful to that transition through conventional getters. Applications create events through named factories on McpMetricsEvent—for example, serverStarted(), requestStarted(endpoint, method), requestFinished(endpoint, method, outcome, duration), and protocolError(code)—not through variant constructors. The nested variants remain public for typed pattern matching:
McpMetricsEvent event = McpMetricsEvent.requestFinished(
"/catalog/mcp",
"tools/call",
McpRequestOutcome.COMPLETE,
Duration.ofMillis(12)
);
if (event instanceof McpMetricsEvent.RequestFinished finished) {
String endpoint = finished.getEndpointPath();
String method = finished.getJsonRpcMethod();
McpRequestOutcome outcome = finished.getOutcome();
Duration duration = finished.getDuration();
}
McpMetricsEvent event = McpMetricsEvent.requestFinished(
"/catalog/mcp",
"tools/call",
McpRequestOutcome.COMPLETE,
Duration.ofMillis(12)
);
if (event instanceof McpMetricsEvent.RequestFinished finished) {
String endpoint = finished.getEndpointPath();
String method = finished.getJsonRpcMethod();
McpRequestOutcome outcome = finished.getOutcome();
Duration duration = finished.getDuration();
}
The built-in cardinality boundary permits registered endpoint paths, recognized/fixed method values, fixed enum outcomes/reasons, fixed error codes, and durations. Unrecognized or attacker-supplied methods collapse to McpMetricsEvent.UNRECOGNIZED_JSON_RPC_METHOD.
Built-in metric dimensions never contain operation names, resource URIs, principals, header names or values, request IDs, trace data, arguments, results, or Throwables.
Stream termination labels
MCP request-stream and subscription metrics use the lowercase names of McpStreamTerminationReason, both in the default collector's text exports and in OpenTelemetry:
| Public reason | Exported value | Meaning |
|---|---|---|
SESSION_EXPIRED | session_expired | A verified 2025 session exceeded its idle or absolute lifetime |
SESSION_CLOSED | session_closed | The session closed, including verified DELETE retirement |
OpenTelemetry records these values under soklet.mcp.stream.termination.reason and soklet.mcp.subscription.termination.reason; the default collector uses reason. They describe the stream's terminal cause. There are no dedicated session-count or session-lifetime instruments, and session IDs are not dimensions. Legacy session retirement other than idle/absolute session expiry or server stop also signals in-flight cancelation tokens as CLIENT_CANCELED, including grant lifetime or lease expiry, authorization loss and capacity eviction. That token category does not establish an explicit user action; the MCP session outcome remains SESSION_CLOSED. Native HTTP stream metrics use the separate uppercase StreamTerminationReason names; use the appropriate vocabulary in dashboards and alerts.
Tasks observability boundary
A task-producing tool invocation is observed as the admitted tools/call request that created its handle. Admitted tasks/get, tasks/update, and tasks/cancel requests receive the same exactly-once lifecycle start/finish callbacks and RequestStarted/RequestFinished events as other MCP requests. Those three task methods are recognized bounded method values, so the default aggregate exposes their request counts, outcomes, and durations without using task IDs as labels. Live requests and off-network simulation share this path.
That is the protocol and manager-call boundary, not the durable work lifecycle. Soklet provides no separate task-created or task-transition observability callback or metrics event, and no built-in metric for task status changes, active tasks, retries, leases, queue depth or backlog, or worker executions. An outbound notifications/tasks projection has no dedicated task-notification counter; its surrounding subscription and stream still use their ordinary metrics.
Instrument those durable transitions in the application-owned task manager, repository or outbox, queue, and worker layers where they become authoritative. Keep dimensions bounded—for example, task kind, transition, and fixed failure category—and never use a task ID, task contents, status message, principal, or origin as a metric label. See MCP Tasks for the complete ownership boundary.
CancelationSignaled intentionally contains only endpoint path and JSON-RPC method. Soklet does not add a cancelation-reason metric dimension. Handlers and StreamingResponseCanceledException instead observe the fixed shared StreamTerminationReason category with no framework cause or free-form reason. This keeps debugging state bounded without turning it into a metric label.
Explicit 2025 client cancelation uses the shared CLIENT_CANCELED token category and MCP REQUEST_CANCELED stream cause. Server session expiry uses token RESPONSE_TIMEOUT and MCP SESSION_EXPIRED; Verified DELETE uses token CLIENT_CANCELED and MCP SESSION_CLOSED. Update exhaustive enum switches and downstream mappings. This adds no public session lifecycle callback, session span, or session-ID/owner-key metric dimension; non-cooperative work retains physical reservations until exit.
Aggregate snapshot
MetricsCollector.Snapshot.getMcpMetrics() returns an immutable McpMetricsSnapshot. The default collector builds counters, gauges, and duration histograms for:
- active handlers, queue depth, and capacity rejections;
- shutdown dispositions, starts, accepted/rejected connections, and fixed transport failures;
- processor-accepted and pre-admission-rejected requests;
- active/completed requests and request duration by endpoint, method, and
McpRequestOutcome; - active request streams and durations by
McpStreamTerminationReason; - active subscriptions and their duration/termination reason;
- delivered subscription-maintenance outcomes by endpoint, fixed work and fixed outcome;
- cancelation, progress, keep-alive, protocol-error, and unknown-mirrored-header events.
Map keys are the final immutable EndpointMethodKey, RequestOutcomeKey, RequestStreamTerminationKey, and SubscriptionTerminationKey classes nested under McpMetricsSnapshot. Each has a type-specific public construction path: EndpointMethodKey.fromDimensions(...), RequestOutcomeKey.fromDimensions(...), RequestStreamTerminationKey.fromDimensions(...), and SubscriptionTerminationKey.fromDimensions(...). EndpointMethodKey.getEndpointPath() and getJsonRpcMethod() expose the common dimensions; RequestOutcomeKey.getOutcome() and the termination keys' getReason() expose their additional dimensions. Constructors and record-style accessors are not public API. Snapshot builder scalars are boxed Long values and maps are immutable. MetricsCollector.reset() clears cumulative values but preserves live gauges, which continue to change as active work exits.
McpMetricsSnapshot.getSubscriptionMaintenance() returns an immutable map of boxed Long counts keyed by SubscriptionMaintenanceKey. Create a key through fromDimensions(String, Work, Outcome); its getEndpointPath(), getWork() and getOutcome() expose the three dimensions, and toString() redacts the endpoint. The builder's subscriptionMaintenance(...) copies its map, rejects null entries and negative counts, preserves explicit zeros, and clears on null or an empty map.
Counts describe delivered outcome events across supported MCP revisions, including coalescing and stale-result discards. They are not unique subscriptions, started attempts, durations or active-work gauges. Maintenance-only records appear in both text formats without requiring a subscription-open event. Work/outcome labels use lowercase enum names. The default collector retains at most 8,192 keys; eviction and semantic-delivery overflow can omit observations. Earlier snapshots stay immutable and quiescent reset clears these counts while preserving live gauges; concurrent snapshots and resets retain the existing weak observation semantics.
Current request outcomes are COMPLETE, INPUT_REQUIRED, REJECTED, APPLICATION_ERROR, PROTOCOL_ERROR, INTERNAL_ERROR, CANCELED, DEADLINE_EXCEEDED, CLIENT_DISCONNECTED, and WRITE_FAILED.
Current stream reasons are COMPLETED, CLIENT_DISCONNECTED, REQUEST_CANCELED, DEADLINE_EXCEEDED, WRITE_FAILED, BACKPRESSURE, SERVER_STOPPING, both simulator capture-limit reasons, and INTERNAL_ERROR. The public vocabulary also includes SESSION_EXPIRED for active-session expiry, SESSION_CLOSED for session retirement, and SUBSCRIPTION_AUTHORIZATION_DENIED, SUBSCRIPTION_AUTHORIZATION_EXPIRED, SUBSCRIPTION_AUTHORIZATION_CHECK_FAILED, and SUBSCRIPTION_RECONCILIATION_FAILED for modern subscription authorization/reconciliation outcomes.
Text metric families
The default Prometheus/OpenMetrics exporter uses these exact 23 base families. Histogram bases additionally produce _bucket, _count, and _sum samples according to MetricsCollector.SnapshotTextOptions.
| Family or families | Labels |
|---|---|
soklet_mcp_server_starts_total | None |
soklet_mcp_shutdowns_total | outcome |
soklet_mcp_connections_accepted_total, soklet_mcp_connections_rejected_total | None |
soklet_transport_failures_total | server_type="MCP", reason (shared transport family) |
soklet_mcp_requests_accepted_total, soklet_mcp_requests_rejected_total, soklet_mcp_requests_active | None |
soklet_mcp_requests_total, soklet_mcp_request_duration_nanos | endpoint, method, outcome |
soklet_mcp_handler_executions_active, soklet_mcp_handler_queue_depth, soklet_mcp_handler_capacity_rejections_total | None |
soklet_mcp_request_streams_active | None |
soklet_mcp_request_stream_duration_nanos | endpoint, method, reason |
soklet_mcp_subscriptions_active | None |
soklet_mcp_subscription_duration_nanos | endpoint, reason |
soklet_mcp_subscription_maintenance_total | endpoint, work, outcome |
soklet_mcp_cancelations_signaled_total, soklet_mcp_progress_emitted_total, soklet_mcp_unknown_mirrored_headers_total | endpoint, method |
soklet_mcp_keep_alives_emitted_total | None |
soklet_mcp_protocol_errors_total | code |
Endpoint labels are finite registered paths. Method, outcome, reason, code, and transport reason use bounded vocabularies. The spelling cancelations is the compatibility spelling used by the public API and metric name.
Diagnostics versus metrics
McpServer.getDiagnostics() is a fresh instantaneous server snapshot: status, effective bind address, configured/active handler capacity, queue depth, live streams/subscriptions, and secret-free security configuration fingerprints. Metrics are an observation stream plus cumulative/live aggregates. Use diagnostics for control-plane readiness and metrics for monitoring. See Server Configuration for the exact diagnostics getters and linearization boundary.
Trace context and correlation
After validation, MCP distributed trace context and W3C baggage are available from McpAdmissionContext and McpRequestContext. They come only from MCP request metadata and do not fall back to HTTP trace headers. Baggage values are decoded and bounded; still treat them as untrusted application input and never as identity.
Supply a purpose-specific key to enable pseudonymous trace-correlation control:
McpTraceCorrelationKey key = McpTraceCorrelationKey.fromIdAndBytes(
"2026-08",
keyBytesFromSecureSecretStorage()
);
McpServer server = McpServer.withPort(8082)
.endpointRegistry(registry)
.admissionController(admissionController)
.traceCorrelationKey(key)
.build();
McpTraceCorrelationKey key = McpTraceCorrelationKey.fromIdAndBytes(
"2026-08",
keyBytesFromSecureSecretStorage()
);
McpServer server = McpServer.withPort(8082)
.endpointRegistry(registry)
.admissionController(admissionController)
.traceCorrelationKey(key)
.build();
McpServer.getTraceCorrelationKeyManager() reports whether correlation is enabled, the non-secret active key ID, and a secret-free 43-character configuration fingerprint. McpTraceCorrelationFingerprint is returned only by Soklet controls and diagnostics: it has no public constructor or application factory, and getValue() reads its canonical unpadded Base64URL value. rotateActiveKey(...) atomically replaces the active McpTraceCorrelationKey; there is no public key history or historical-token derivation. Reusing the active ID with different bytes is rejected, as is key material equal to request-state protection material.
Key IDs are 1–64-byte ASCII HTTP tokens. Key material must have at least 32 bytes from a cryptographically secure source, is defensively copied, and is redacted from rendering.
Raw trace IDs require a separate opt-in
An admitted request with correlation data attempts one LogEventType.MCP_TRACE_CORRELATION record at its exactly-once finish authority. Token mode logs only the versioned format, key ID, and 22-character pseudonymous token. logRawValidatedTraceIds(true) independently permits the validated 32-character lowercase trace ID; with no correlation key it can attempt a trace-ID-only record. The event never contains the full traceparent, parent/span ID, flags, tracestate, baggage, request or resource-method objects, marshaled response, or Throwable. Raw and pseudonymous IDs remain absent from metrics, exceptions, and conformance artifacts.
The message uses one of three bounded ASCII shapes, in stable field order:
tokenFormat=soklet-mcp-trace-correlation-v1;keyId=<key-id>;token=<token>
tokenFormat=soklet-mcp-trace-correlation-v1;keyId=<key-id>;token=<token>;traceId=<trace-id>
traceId=<trace-id>
tokenFormat=soklet-mcp-trace-correlation-v1;keyId=<key-id>;token=<token>
tokenFormat=soklet-mcp-trace-correlation-v1;keyId=<key-id>;token=<token>;traceId=<trace-id>
traceId=<trace-id>
The maximum message length is 184 characters. Treat raw trace IDs as sensitive, high-cardinality application logs and set retention/access controls accordingly. Validate sustained logging capacity and retention in your deployment.
To reproduce a soklet-mcp-trace-correlation-v1 token, apply HMAC-SHA-256 with the captured key to the UTF-8 bytes of soklet-mcp-trace-correlation-v1\0 (including the final NUL byte), followed by the decoded 16-byte trace ID. Take the first 16 digest bytes and encode unpadded Base64URL, giving 22 characters. Invalid or all-zero trace IDs are rejected. Fingerprints compare configuration; avoid per-request fingerprint logging and unbounded fingerprint histories.
Off-network simulator
Pass the application's completed SokletConfig to SokletSimulator.run(sokletConfig, simulation). Each call derives a fresh, transport-isolated off-network MCP server from the application's server construction settings. The simulator call never starts, claims, or changes the source listener, and it supplies a scoped Simulator with fresh framework-owned runtime state. Explicit application collaborators—including MCP rate limiters, the McpTaskManager, and its stable McpTaskEventPublisher—are reused by identity; configuration-dependent defaults are derived again, and tests remain responsible for isolating collaborator-owned mutable state. This is not a deep copy: Soklet does not inspect or rebind a dependency-injection provider or other collaborator that captured the source server. The simulator's startMcpRequest(...) method then drives a synthetic MCP Request asynchronously through the real protocol processor, admission controller and policy hooks, handler dispatch, lifecycle, and applicable metrics without binding a socket.
The example assumes obtainMySokletConfig() returns the completed configuration for the minimal annotated endpoint: the local development MCP server on logical port 8082, anonymous admission, and the catalog.search endpoint. If you import a production configuration instead, change the synthetic Host and credentials to match that configuration's admission policy.
SokletConfig sokletConfig = obtainMySokletConfig();
SokletSimulator.run(
sokletConfig,
simulator -> {
String body = """
{
"jsonrpc": "2.0",
"id": "test-1",
"method": "tools/call",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
},
"name": "catalog.search",
"arguments": {"query": "shoe"}
}
}
""";
Request request = Request.withPath(
HttpMethod.POST, "/catalog/mcp")
.headers(Map.of(
"Host", List.of("127.0.0.1:8082"),
"Content-Type", List.of("application/json"),
"Accept", List.of("application/json, text/event-stream"),
"MCP-Protocol-Version", List.of("2026-07-28"),
"Mcp-Method", List.of("tools/call"),
"Mcp-Name", List.of("catalog.search")
))
.body(body.getBytes(StandardCharsets.UTF_8))
.build();
try (McpSimulation simulation = simulator.startMcpRequest(
request,
McpSimulationOptions.builder()
.streamItemQueueCapacity(64)
.maximumCapturedSizeInBytes(2 * 1024 * 1024)
.build())) {
McpSimulationResponse response = simulation
.awaitResponse(Duration.ofSeconds(5))
.orElseThrow();
while (!simulation.isComplete()) {
simulation.awaitStreamItem(Duration.ofMillis(100))
.ifPresent(item -> assertFrame(item.getEncodedBytes()));
}
McpSimulationCompletion completion = simulation
.awaitCompletion(Duration.ofSeconds(5))
.orElseThrow();
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new AssertionError(
"Interrupted while awaiting MCP simulation", exception);
}
}
);
SokletConfig sokletConfig = obtainMySokletConfig();
SokletSimulator.run(
sokletConfig,
simulator -> {
String body = """
{
"jsonrpc": "2.0",
"id": "test-1",
"method": "tools/call",
"params": {
"_meta": {
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientCapabilities": {}
},
"name": "catalog.search",
"arguments": {"query": "shoe"}
}
}
""";
Request request = Request.withPath(
HttpMethod.POST, "/catalog/mcp")
.headers(Map.of(
"Host", List.of("127.0.0.1:8082"),
"Content-Type", List.of("application/json"),
"Accept", List.of("application/json, text/event-stream"),
"MCP-Protocol-Version", List.of("2026-07-28"),
"Mcp-Method", List.of("tools/call"),
"Mcp-Name", List.of("catalog.search")
))
.body(body.getBytes(StandardCharsets.UTF_8))
.build();
try (McpSimulation simulation = simulator.startMcpRequest(
request,
McpSimulationOptions.builder()
.streamItemQueueCapacity(64)
.maximumCapturedSizeInBytes(2 * 1024 * 1024)
.build())) {
McpSimulationResponse response = simulation
.awaitResponse(Duration.ofSeconds(5))
.orElseThrow();
while (!simulation.isComplete()) {
simulation.awaitStreamItem(Duration.ofMillis(100))
.ifPresent(item -> assertFrame(item.getEncodedBytes()));
}
McpSimulationCompletion completion = simulation
.awaitCompletion(Duration.ofSeconds(5))
.orElseThrow();
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new AssertionError(
"Interrupted while awaiting MCP simulation", exception);
}
}
);
Use SimulatorConfig.fromSokletConfig(sokletConfig) when a completed single-use value is useful. Use SimulatorConfig.withSokletConfig(sokletConfig) when one test needs to customize the imported MCP builder before the framework builds it. configureMcpServer(Consumer) customizes the imported MCP settings:
SimulatorConfig simulatorConfig = SimulatorConfig
.withSokletConfig(sokletConfig)
.configureMcpServer(builder -> builder
.requestTimeout(Duration.ofSeconds(2)))
.build();
SimulatorConfig simulatorConfig = SimulatorConfig
.withSokletConfig(sokletConfig)
.configureMcpServer(builder -> builder
.requestTimeout(Duration.ofSeconds(2)))
.build();
The same method can replace a reused application collaborator with a test-scoped implementation when its mutable state needs isolation.
For a standalone simulator with no source configuration, the same configureMcpServer(Consumer) method supplies a fresh MCP builder whose logical port defaults to 0. Override it with port(Integer) when a test needs another logical value, such as 8082 for the request above. The fresh builder uses the same generated-endpoint discovery and accept-all admission defaults as McpServer.withPort(port). Apply the required settings from local development configuration to that supplied builder, with optional tool rate limiting, and make each synthetic Host match its logical port and allowed authority.
A completed SimulatorConfig can be claimed by exactly one run.
For Tasks, sequential simulator requests share the configured manager just as production requests do: create a task through tools/call, transition application-owned state between requests, then exercise tasks/get, tasks/update, tasks/cancel, or a taskIds subscription. Use configureMcpServer(...) to replace a production manager with an isolated test manager when sharing its mutable state would make tests interfere. The simulator uses the production Tasks protocol path; it does not create a parallel simulator-only task model. See MCP Tasks.
Importing from a SokletConfig preserves every transport type present in the source. Use the standalone SimulatorConfig.builder() form when a test must omit one.
The Host value must match the configured MCP server authority even though no socket is bound; the simulator does not inject or repair it. Construct requests exactly as a client would, including content negotiation, protocol/method/name headers, and per-request metadata. This makes policy and validation failures reproducible instead of silently bypassing them.
McpSimulationOptions.defaultInstance() allows 128 pending SSE items and 10 MiB of cumulative captured response/frame bytes. Consuming an item releases an item slot but never refunds captured bytes.
Response and stream capture
McpSimulationResponse contains status, coalesced insertion-ordered headers, a body type, and an optional defensive-copy body. Types are EMPTY, JSON, and SSE. Empty bodies return a present zero-length array; SSE is consumed as stream items; oversized JSON has no captured body.
McpSimulation.awaitStreamItem(...) destructively removes one JSON_MESSAGE or KEEP_ALIVE_COMMENT. An item exposes the parsed JSON or exact keepalive comment and a fresh copy of its canonical unchunked SSE bytes. Response-head and completion reads are repeatable; stream-item reads are not.
McpSimulationCompletion exposes a fixed termination reason, an optional duplicated terminal SSE message, and ordered Throwables. Those exact Throwable identities can be sensitive. McpSimulation.close() idempotently simulates a disconnect and cannot replace an earlier terminal winner; already captured values remain readable.
What simulation does not prove
Simulation does not start the listener, change bound-address/status diagnostics, or advertise a capability. It cannot prove kernel socket behavior, TCP disconnect timing, chunking, listener connection capacity, real write-idle/backpressure behavior, proxy behavior, or TLS integration. Use live-network integration tests for those concerns.
The off-network channel has no live write-idle deadline. Simulator item/byte capture limits determine capture termination; they cannot substitute for testing slow readers, socket backpressure, or write-idle closure against a real listener.
Testing Completion
Completion can be exercised through the same simulator request path as tools and prompts. Send the literal prompt/template reference, partial argument, optional context arguments, and normal per-request metadata; omit Mcp-Name. Configure the required server-wide request limiter in the simulated server as well.
For your completers, test empty and 100-value results, rejection of 101 values, Unicode, ordering, duplicate preservation, optional counts, metadata, and invalid totals. Exercise locale selection, fallback, and tenant isolation with your own application data. Use live-listener tests for deadlines, disconnect and shutdown cancelation, and progress delivery. Test interoperability with the client and host versions you deploy.
Testing localized responses
Simulation is the fastest way to prove localization behavior off-network: a configured localizer renders through the same code path as a live listener, so you can assert exact localized bytes, Content-Language, Vary, whole-response fallback, and catalog-invalidation notifications without binding a port. A successful subscriptions/listen opening carries the selected language, or the configured fallback language after whole-response fallback, because its terminal metadata is pre-rendered before the SSE head commits. Reload invalidation may discard stale terminal text later but does not renegotiate that stream's intended audience. Locale never becomes a metric dimension, so a flood of unique tags creates no new series. See Localization.

