Skip to content

Commit e5d8fa1

Browse files
Merge branch 'main' into fix/547-dispose-on-error
2 parents 2ab0e25 + fa9dac8 commit e5d8fa1

File tree

81 files changed

+5204
-1255
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

81 files changed

+5204
-1255
lines changed

README.md

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,13 +47,141 @@ Please follow the [Contributing Guidelines](CONTRIBUTING.md).
4747

4848
- Christian Tzolov
4949
- Dariusz Jędrzejczyk
50+
- Daniel Garnier-Moiroux
5051

5152
## Links
5253

5354
- [GitHub Repository](https://github.com/modelcontextprotocol/java-sdk)
5455
- [Issue Tracker](https://github.com/modelcontextprotocol/java-sdk/issues)
5556
- [CI/CD](https://github.com/modelcontextprotocol/java-sdk/actions)
5657

58+
## Architecture and Design Decisions
59+
60+
### Introduction
61+
62+
Building a general-purpose MCP Java SDK requires making technology decisions in areas where the JDK provides limited or no support. The Java ecosystem is powerful but fragmented: multiple valid approaches exist, each with strong communities.
63+
Our goal is not to prescribe "the one true way," but to provide a reference implementation of the MCP specification that is:
64+
65+
* **Pragmatic** – makes developers productive quickly
66+
* **Interoperable** – aligns with widely used libraries and practices
67+
* **Pluggable** – allows alternatives where projects prefer different stacks
68+
* **Grounded in team familiarity** – we chose technologies the team can be productive with today, while remaining open to community contributions that broaden the SDK
69+
70+
### Key Choices and Considerations
71+
72+
The SDK had to make decisions in the following areas:
73+
74+
1. **JSON serialization** – mapping between JSON and Java types
75+
76+
2. **Programming model** – supporting asynchronous processing, cancellation, and streaming while staying simple for blocking use cases
77+
78+
3. **Observability** – logging and enabling integration with metrics/tracing
79+
80+
4. **Remote clients and servers** – supporting both consuming MCP servers (client transport) and exposing MCP endpoints (server transport with authorization)
81+
82+
The following sections explain what we chose, why it made sense, and how the choices align with the SDK's goals.
83+
84+
### 1. JSON Serialization
85+
86+
* **SDK Choice**: Jackson for JSON serialization and deserialization, behind an SDK abstraction (`mcp-json`)
87+
88+
* **Why**: Jackson is widely adopted across the Java ecosystem, provides strong performance and a mature annotation model, and is familiar to the SDK team and many potential contributors.
89+
90+
* **How we expose it**: Public APIs use a zero-dependency abstraction (`mcp-json`). Jackson is shipped as the default implementation (`mcp-jackson2`), but alternatives can be plugged in.
91+
92+
* **How it fits the SDK**: This offers a pragmatic default while keeping flexibility for projects that prefer different JSON libraries.
93+
94+
### 2. Programming Model
95+
96+
* **SDK Choice**: Reactive Streams for public APIs, with Project Reactor as the internal implementation and a synchronous facade for blocking use cases
97+
98+
* **Why**: MCP builds on JSON-RPC's asynchronous nature and defines a bidirectional protocol on top of it, enabling asynchronous and streaming interactions. MCP explicitly supports:
99+
100+
* Multiple in-flight requests and responses
101+
* Notifications that do not expect a reply
102+
* STDIO transports for inter-process communication using pipes
103+
* Streaming transports such as Server-Sent Events and Streamable HTTP
104+
105+
These requirements call for a programming model more powerful than single-result futures like `CompletableFuture`.
106+
107+
* **Reactive Streams: the Community Standard**
108+
109+
Reactive Streams is a small Java specification that standardizes asynchronous stream processing with backpressure. It defines four minimal interfaces (Publisher, Subscriber, Subscription, and Processor). These interfaces are widely recognized as the standard contract for async, non-blocking pipelines in Java.
110+
111+
* **Reactive Streams Implementation**
112+
113+
The SDK uses Project Reactor as its implementation of the Reactive Streams specification. Reactor is mature, widely adopted, provides rich operators, and integrates well with observability through context propagation. Team familiarity also allowed us to deliver a solid foundation quickly.
114+
We plan to convert the public API to only expose Reactive Streams interfaces. By defining the public API in terms of Reactive Streams interfaces and using Reactor internally, the SDK stays standards-based while benefiting from a practical, production-ready implementation.
115+
116+
* **Synchronous Facade in the SDK**
117+
118+
Not all MCP use cases require streaming pipelines. Many scenarios are as simple as "send a request and block until I get the result."
119+
To support this, the SDK provides a synchronous facade layered on top of the reactive core. Developers can stay in a blocking model when it's enough, while still having access to asynchronous streaming when needed.
120+
121+
* **How it fits the SDK**: This design balances scalability, approachability, and future evolution such as Virtual Threads and Structured Concurrency in upcoming JDKs.
122+
123+
### 3. Observability
124+
125+
* **SDK Choice**: SLF4J for logging; Reactor Context for observability propagation
126+
127+
* **Why**: SLF4J is the de facto logging facade in Java, with broad compatibility. Reactor Context enables propagation of observability data such as correlation IDs and tracing state across async boundaries. This ensures interoperability with modern observability frameworks.
128+
129+
* **How we expose it**: Public APIs log through SLF4J only, with no backend included. Observability metadata flows through Reactor pipelines. The SDK itself does not ship metrics or tracing implementations.
130+
131+
* **How it fits the SDK**: This provides reliable logging by default and seamless integration with Micrometer, OpenTelemetry, or similar systems for metrics and tracing.
132+
133+
### 4. Remote MCP Clients and Servers
134+
135+
MCP supports both clients (applications consuming MCP servers) and servers (applications exposing MCP endpoints). The SDK provides support for both sides.
136+
137+
#### Client Transport in the SDK
138+
139+
* **SDK Choice**: JDK HttpClient (Java 11+) as the default client, with optional Spring WebClient support
140+
141+
* **Why**: The JDK HttpClient is built-in, portable, and supports streaming responses. This keeps the default lightweight with no extra dependencies. Spring WebClient support is available for Spring-based projects.
142+
143+
* **How we expose it**: MCP Client APIs are transport-agnostic. The core module ships with JDK HttpClient transport. A Spring module provides WebClient integration.
144+
145+
* **How it fits the SDK**: This ensures all applications can talk to MCP servers out of the box, while allowing richer integration in Spring and other environments.
146+
147+
#### Server Transport in the SDK
148+
149+
* **SDK Choice**: Jakarta Servlet implementation in core, with optional Spring WebFlux and Spring WebMVC providers
150+
151+
* **Why**: Servlet is the most widely deployed Java server API. WebFlux and WebMVC cover a significant part of the Spring community. Together these provide reach across blocking and non-blocking models.
152+
153+
* **How we expose it**: Server APIs are transport-agnostic. Core includes Servlet support. Spring modules extend support for WebFlux and WebMVC.
154+
155+
* **How it fits the SDK**: This allows developers to expose MCP servers in the most common Java environments today, while enabling other transport implementations such as Netty, Vert.x, or Helidon.
156+
157+
#### Authorization in the SDK
158+
159+
* **SDK Choice**: Pluggable authorization hooks for MCP servers; no built-in implementation
160+
161+
* **Why**: MCP servers must restrict access to authenticated and authorized clients. Authorization needs differ across environments such as Spring Security, MicroProfile JWT, or custom solutions. Providing hooks avoids lock-in and leverages proven libraries.
162+
163+
* **How we expose it**: Authorization is integrated into the server transport layer. The SDK does not include its own authorization system.
164+
165+
* **How it fits the SDK**: This keeps server-side security ecosystem-neutral, while ensuring applications can plug in their preferred authorization strategy.
166+
167+
### Project Structure of the SDK
168+
169+
The SDK is organized into modules to separate concerns and allow adopters to bring in only what they need:
170+
* `mcp-bom` – Dependency versions
171+
* `mcp-core` – Reference implementation (STDIO, JDK HttpClient, Servlet)
172+
* `mcp-json` – JSON abstraction
173+
* `mcp-jackson2` – Jackson implementation of JSON binding
174+
* `mcp` – Convenience bundle (core + Jackson)
175+
* `mcp-test` – Shared testing utilities
176+
* `mcp-spring` – Spring integrations (WebClient, WebFlux, WebMVC)
177+
178+
For example, a minimal adopter may depend only on `mcp` (core + Jackson), while a Spring-based application can use `mcp-spring` for deeper framework integration.
179+
180+
### Future Directions
181+
182+
The SDK is designed to evolve with the Java ecosystem. Areas we are actively watching include:
183+
Concurrency in the JDK – Virtual Threads and Structured Concurrency may simplify the synchronous API story
184+
57185
## License
58186

59187
This project is licensed under the [MIT License](LICENSE).

mcp-bom/pom.xml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
<parent>
88
<groupId>io.modelcontextprotocol.sdk</groupId>
99
<artifactId>mcp-parent</artifactId>
10-
<version>0.14.0-SNAPSHOT</version>
10+
<version>0.18.0-SNAPSHOT</version>
1111
</parent>
1212

1313
<artifactId>mcp-bom</artifactId>

mcp-core/pom.xml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
<parent>
77
<groupId>io.modelcontextprotocol.sdk</groupId>
88
<artifactId>mcp-parent</artifactId>
9-
<version>0.14.0-SNAPSHOT</version>
9+
<version>0.18.0-SNAPSHOT</version>
1010
</parent>
1111
<artifactId>mcp-core</artifactId>
1212
<packaging>jar</packaging>
@@ -68,7 +68,7 @@
6868
<dependency>
6969
<groupId>io.modelcontextprotocol.sdk</groupId>
7070
<artifactId>mcp-json</artifactId>
71-
<version>0.14.0-SNAPSHOT</version>
71+
<version>0.18.0-SNAPSHOT</version>
7272
</dependency>
7373

7474
<dependency>
@@ -101,7 +101,7 @@
101101
<dependency>
102102
<groupId>io.modelcontextprotocol.sdk</groupId>
103103
<artifactId>mcp-json-jackson2</artifactId>
104-
<version>0.14.0-SNAPSHOT</version>
104+
<version>0.18.0-SNAPSHOT</version>
105105
<scope>test</scope>
106106
</dependency>
107107

mcp-core/src/main/java/io/modelcontextprotocol/client/LifecycleInitializer.java

Lines changed: 32 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,13 @@
1111
import java.util.concurrent.atomic.AtomicReference;
1212
import java.util.function.Function;
1313

14-
import org.slf4j.Logger;
15-
import org.slf4j.LoggerFactory;
16-
1714
import io.modelcontextprotocol.spec.McpClientSession;
1815
import io.modelcontextprotocol.spec.McpError;
1916
import io.modelcontextprotocol.spec.McpSchema;
2017
import io.modelcontextprotocol.spec.McpTransportSessionNotFoundException;
2118
import io.modelcontextprotocol.util.Assert;
19+
import org.slf4j.Logger;
20+
import org.slf4j.LoggerFactory;
2221
import reactor.core.publisher.Mono;
2322
import reactor.core.publisher.Sinks;
2423
import reactor.util.context.ContextView;
@@ -99,21 +98,30 @@ class LifecycleInitializer {
9998
*/
10099
private final Duration initializationTimeout;
101100

101+
/**
102+
* Post-initialization hook to perform additional operations after every successful
103+
* initialization.
104+
*/
105+
private final Function<Initialization, Mono<Void>> postInitializationHook;
106+
102107
public LifecycleInitializer(McpSchema.ClientCapabilities clientCapabilities, McpSchema.Implementation clientInfo,
103108
List<String> protocolVersions, Duration initializationTimeout,
104-
Function<ContextView, McpClientSession> sessionSupplier) {
109+
Function<ContextView, McpClientSession> sessionSupplier,
110+
Function<Initialization, Mono<Void>> postInitializationHook) {
105111

106112
Assert.notNull(sessionSupplier, "Session supplier must not be null");
107113
Assert.notNull(clientCapabilities, "Client capabilities must not be null");
108114
Assert.notNull(clientInfo, "Client info must not be null");
109115
Assert.notEmpty(protocolVersions, "Protocol versions must not be empty");
110116
Assert.notNull(initializationTimeout, "Initialization timeout must not be null");
117+
Assert.notNull(postInitializationHook, "Post-initialization hook must not be null");
111118

112119
this.sessionSupplier = sessionSupplier;
113120
this.clientCapabilities = clientCapabilities;
114121
this.clientInfo = clientInfo;
115122
this.protocolVersions = Collections.unmodifiableList(new ArrayList<>(protocolVersions));
116123
this.initializationTimeout = initializationTimeout;
124+
this.postInitializationHook = postInitializationHook;
117125
}
118126

119127
/**
@@ -148,10 +156,6 @@ interface Initialization {
148156

149157
}
150158

151-
/**
152-
* Default implementation of the {@link Initialization} interface that manages the MCP
153-
* client initialization process.
154-
*/
155159
private static class DefaultInitialization implements Initialization {
156160

157161
/**
@@ -199,29 +203,20 @@ private void setMcpClientSession(McpClientSession mcpClientSession) {
199203
this.mcpClientSession.set(mcpClientSession);
200204
}
201205

202-
/**
203-
* Returns a Mono that completes when the MCP client initialization is complete.
204-
* This allows subscribers to wait for the initialization to finish before
205-
* proceeding with further operations.
206-
* @return A Mono that emits the result of the MCP initialization process
207-
*/
208206
private Mono<McpSchema.InitializeResult> await() {
209207
return this.initSink.asMono();
210208
}
211209

212-
/**
213-
* Completes the initialization process with the given result. It caches the
214-
* result and emits it to all subscribers waiting for the initialization to
215-
* complete.
216-
* @param initializeResult The result of the MCP initialization process
217-
*/
218210
private void complete(McpSchema.InitializeResult initializeResult) {
219-
// first ensure the result is cached
220-
this.result.set(initializeResult);
221211
// inform all the subscribers waiting for the initialization
222212
this.initSink.emitValue(initializeResult, Sinks.EmitFailureHandler.FAIL_FAST);
223213
}
224214

215+
private void cacheResult(McpSchema.InitializeResult initializeResult) {
216+
// first ensure the result is cached
217+
this.result.set(initializeResult);
218+
}
219+
225220
private void error(Throwable t) {
226221
this.initSink.emitError(t, Sinks.EmitFailureHandler.FAIL_FAST);
227222
}
@@ -263,7 +258,7 @@ public void handleException(Throwable t) {
263258
}
264259
// Providing an empty operation since we are only interested in triggering
265260
// the implicit initialization step.
266-
withIntitialization("re-initializing", result -> Mono.empty()).subscribe();
261+
this.withInitialization("re-initializing", result -> Mono.empty()).subscribe();
267262
}
268263
}
269264

@@ -275,28 +270,32 @@ public void handleException(Throwable t) {
275270
* @param operation The operation to execute when the client is initialized
276271
* @return A Mono that completes with the result of the operation
277272
*/
278-
public <T> Mono<T> withIntitialization(String actionName, Function<Initialization, Mono<T>> operation) {
273+
public <T> Mono<T> withInitialization(String actionName, Function<Initialization, Mono<T>> operation) {
279274
return Mono.deferContextual(ctx -> {
280275
DefaultInitialization newInit = new DefaultInitialization();
281276
DefaultInitialization previous = this.initializationRef.compareAndExchange(null, newInit);
282277

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

286-
Mono<McpSchema.InitializeResult> initializationJob = needsToInitialize ? doInitialize(newInit, ctx)
287-
: previous.await();
281+
Mono<McpSchema.InitializeResult> initializationJob = needsToInitialize
282+
? this.doInitialize(newInit, this.postInitializationHook, ctx) : previous.await();
288283

289284
return initializationJob.map(initializeResult -> this.initializationRef.get())
290285
.timeout(this.initializationTimeout)
291286
.onErrorResume(ex -> {
292287
this.initializationRef.compareAndSet(newInit, null);
293288
return Mono.error(new RuntimeException("Client failed to initialize " + actionName, ex));
294289
})
295-
.flatMap(operation);
290+
.flatMap(res -> operation.apply(res)
291+
.contextWrite(c -> c.put(McpAsyncClient.NEGOTIATED_PROTOCOL_VERSION,
292+
res.initializeResult().protocolVersion())));
296293
});
297294
}
298295

299-
private Mono<McpSchema.InitializeResult> doInitialize(DefaultInitialization initialization, ContextView ctx) {
296+
private Mono<McpSchema.InitializeResult> doInitialize(DefaultInitialization initialization,
297+
Function<Initialization, Mono<Void>> postInitOperation, ContextView ctx) {
298+
300299
initialization.setMcpClientSession(this.sessionSupplier.apply(ctx));
301300

302301
McpClientSession mcpClientSession = initialization.mcpSession();
@@ -322,7 +321,12 @@ private Mono<McpSchema.InitializeResult> doInitialize(DefaultInitialization init
322321
}
323322

324323
return mcpClientSession.sendNotification(McpSchema.METHOD_NOTIFICATION_INITIALIZED, null)
324+
.contextWrite(
325+
c -> c.put(McpAsyncClient.NEGOTIATED_PROTOCOL_VERSION, initializeResult.protocolVersion()))
325326
.thenReturn(initializeResult);
327+
}).flatMap(initializeResult -> {
328+
initialization.cacheResult(initializeResult);
329+
return postInitOperation.apply(initialization).thenReturn(initializeResult);
326330
}).doOnNext(initialization::complete).onErrorResume(ex -> {
327331
initialization.error(ex);
328332
return Mono.error(ex);

0 commit comments

Comments
 (0)