Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions docs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,26 @@ The client provides both synchronous and asynchronous APIs for flexibility in di
.subscribe();
```

### Custom Initialize Request

By default, `initialize()` builds the request from client builder settings (protocol version, capabilities, client info). To control the full initialize payload — including the optional `_meta` field — use the `initialize(InitializeRequest)` overload:

```java
InitializeRequest request = InitializeRequest
.builder(ProtocolVersions.MCP_2025_11_25, client.getClientCapabilities(), client.getClientInfo())
.meta(Map.of("server_id", "proxy-1", "invocation_id", "abc-123"))
.build();

// Call before any other client operation so the custom request is sent.
client.initialize(request);
```

The async client exposes the same overload and returns `Mono<InitializeResult>`.

If another client method triggers lazy initialization first, the default request is sent instead. Call `initialize(request)` before `listTools()`, `callTool()`, or similar operations when custom initialize metadata is required.

After a successful `initialize(InitializeRequest)`, the client remembers that request and resends it when the transport session is re-established (for example after a `McpTransportSessionNotFoundException`). The stored request is cleared when the client is closed.

## Client Transport

The transport layer handles the communication between MCP clients and servers, providing different implementations for various use cases. The client transport manages message serialization, connection establishment, and protocol-specific communication patterns.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@
import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Function;

Expand Down Expand Up @@ -93,6 +95,8 @@ class LifecycleInitializer {

private final AtomicReference<DefaultInitialization> initializationRef = new AtomicReference<>();

private final AtomicReference<McpSchema.InitializeRequest> storedCustomInitializeRequest = new AtomicReference<>();

/**
* The max timeout to await for the client-server connection to be initialized.
*/
Expand Down Expand Up @@ -257,7 +261,8 @@ public void handleException(Throwable t) {
}
// Providing an empty operation since we are only interested in triggering
// the implicit initialization step.
this.withInitialization("re-initializing", result -> Mono.empty()).subscribe();
this.withInitialization(this.storedCustomInitializeRequest.get(), "re-initializing", result -> Mono.empty())
.subscribe();
}
}

Expand All @@ -270,15 +275,43 @@ public void handleException(Throwable t) {
* @return A Mono that completes with the result of the operation
*/
public <T> Mono<T> withInitialization(String actionName, Function<Initialization, Mono<T>> operation) {
return this.withInitialization(null, actionName, operation);
}

/**
* Utility method to ensure the initialization is established before executing an
* operation, using a caller-provided initialize request.
* @param <T> The type of the result Mono
* @param initializeRequest The initialize request to send; may be null to use the
* default. Only applied when this call triggers the initialization, otherwise
* ignored.
* @param actionName The action to perform when the client is initialized
* @param operation The operation to execute when the client is initialized
* @return A Mono that completes with the result of the operation
*/
public <T> Mono<T> withInitialization(McpSchema.InitializeRequest initializeRequest, String actionName,
Function<Initialization, Mono<T>> operation) {
// Snapshot eagerly so mutating the source _meta map before subscription cannot
// change what is sent.
McpSchema.InitializeRequest sanitizedRequest = initializeRequest != null
? sanitizeInitializeRequest(initializeRequest) : null;
return Mono.deferContextual(ctx -> {
DefaultInitialization newInit = new DefaultInitialization();
DefaultInitialization previous = this.initializationRef.compareAndExchange(null, newInit);

boolean needsToInitialize = previous == null;
logger.debug(needsToInitialize ? "Initialization process started" : "Joining previous initialization");

Mono<McpSchema.InitializeResult> initializationJob = needsToInitialize
? this.doInitialize(newInit, this.postInitializationHook, ctx) : previous.await();
Mono<McpSchema.InitializeResult> initializationJob;
if (needsToInitialize) {
initializationJob = this.doInitialize(newInit, sanitizedRequest, this.postInitializationHook, ctx);
}
else {
if (sanitizedRequest != null) {
logger.debug("Custom initialize request ignored; client is already initialized");
}
initializationJob = previous.await();
}

return initializationJob.map(initializeResult -> this.initializationRef.get())
.timeout(this.initializationTimeout)
Expand All @@ -292,19 +325,43 @@ public <T> Mono<T> withInitialization(String actionName, Function<Initialization
});
}

private static McpSchema.InitializeRequest sanitizeInitializeRequest(McpSchema.InitializeRequest request) {
if (request.meta() == null) {
return request;
}
return McpSchema.InitializeRequest
.builder(request.protocolVersion(), request.capabilities(), request.clientInfo())
.meta(Collections.unmodifiableMap(new HashMap<>(request.meta())))
.build();
}

private McpSchema.InitializeRequest buildInitializeRequest(McpSchema.InitializeRequest customRequest) {
if (customRequest != null) {
return customRequest;
}
String latestVersion = this.protocolVersions.get(this.protocolVersions.size() - 1);
return McpSchema.InitializeRequest.builder(latestVersion, this.clientCapabilities, this.clientInfo).build();
}

private Mono<McpSchema.InitializeResult> doInitialize(DefaultInitialization initialization,
Function<Initialization, Mono<Void>> postInitOperation, ContextView ctx) {
McpSchema.InitializeRequest customRequest, Function<Initialization, Mono<Void>> postInitOperation,
ContextView ctx) {

McpSchema.InitializeRequest initializeRequest = this.buildInitializeRequest(customRequest);

if (!this.protocolVersions.contains(initializeRequest.protocolVersion())) {
McpError error = McpError.builder(-32602)
.message("Unsupported protocol version")
.data("Unsupported protocol version in initialize request: " + initializeRequest.protocolVersion())
.build();
initialization.error(error);
return Mono.error(error);
}

initialization.setMcpClientSession(this.sessionSupplier.apply(ctx));

McpClientSession mcpClientSession = initialization.mcpSession();

String latestVersion = this.protocolVersions.get(this.protocolVersions.size() - 1);

McpSchema.InitializeRequest initializeRequest = McpSchema.InitializeRequest
.builder(latestVersion, this.clientCapabilities, this.clientInfo)
.build();

Mono<McpSchema.InitializeResult> result = mcpClientSession.sendRequest(McpSchema.METHOD_INITIALIZE,
initializeRequest, McpAsyncClient.INITIALIZE_RESULT_TYPE_REF);

Expand All @@ -327,6 +384,10 @@ private Mono<McpSchema.InitializeResult> doInitialize(DefaultInitialization init
}).flatMap(initializeResult -> {
initialization.cacheResult(initializeResult);
return postInitOperation.apply(initialization).thenReturn(initializeResult);
}).doOnNext(initializeResult -> {
if (customRequest != null) {
this.storedCustomInitializeRequest.set(customRequest);
}
}).doOnNext(initialization::complete).onErrorResume(ex -> {
initialization.error(ex);
return Mono.error(ex);
Expand All @@ -341,6 +402,7 @@ public void close() {
if (current != null) {
current.close();
}
this.storedCustomInitializeRequest.set(null);
}

/**
Expand All @@ -350,9 +412,10 @@ public void close() {
public Mono<?> closeGracefully() {
return Mono.defer(() -> {
DefaultInitialization current = this.initializationRef.getAndSet(null);
this.storedCustomInitializeRequest.set(null);
Mono<?> sessionClose = current != null ? current.closeGracefully() : Mono.empty();
return sessionClose;
});
}

}
}
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,25 @@ public Mono<McpSchema.InitializeResult> initialize() {
return this.initializer.withInitialization("by explicit API call", init -> Mono.just(init.initializeResult()));
}

/**
* Initializes the client using a caller-provided initialize request.
* <p>
* Use this overload to control the full initialize request payload, including the
* optional {@code _meta} field. Call this method before any other client operation to
* ensure the custom request is sent; lazy initialization triggered by other methods
* uses the default request built from client builder settings. After a successful
* call, the custom request is remembered and resent on transport session recovery; it
* is cleared when the client is closed.
* @param initializeRequest the initialize request to send
* @return the initialize result
* @see #initialize()
*/
public Mono<McpSchema.InitializeResult> initialize(McpSchema.InitializeRequest initializeRequest) {
Assert.notNull(initializeRequest, "InitializeRequest must not be null");
return this.initializer.withInitialization(initializeRequest, "by explicit API call with custom request",
init -> Mono.just(init.initializeResult()));
}

// --------------------------
// Basic Utilities
// --------------------------
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,23 @@ public McpSchema.InitializeResult initialize() {
return withProvidedContext(this.delegate.initialize()).block();
}

/**
* Initializes the client using a caller-provided initialize request.
* <p>
* Use this overload to control the full initialize request payload, including the
* optional {@code _meta} field. Call this method before any other client operation to
* ensure the custom request is sent; lazy initialization triggered by other methods
* uses the default request built from client builder settings. After a successful
* call, the custom request is remembered and resent on transport session recovery; it
* is cleared when the client is closed.
* @param initializeRequest the initialize request to send
* @return the initialize result
* @see #initialize()
*/
public McpSchema.InitializeResult initialize(McpSchema.InitializeRequest initializeRequest) {
return withProvidedContext(this.delegate.initialize(initializeRequest)).block();
}

/**
* Send a roots/list_changed notification.
*/
Expand Down
Loading