Skip to content
Merged
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
12 changes: 10 additions & 2 deletions changes.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,16 @@ Changes log
`setMaxListenStreams()`, no time limit, and end when the adapter stops or the client
goes away. Over stdio, a message whose `_meta` names 2026-07-28 goes to the modern
server: streamed messages are written as separate lines as they come, and
`notifications/cancelled` ends the request it names without a response. Not yet
supported for 2026-07-28: the MCP client. The SDK's modern API is a preview.
`notifications/cancelled` ends the request it names without a response.
`McpClientAdapter.setProtocolVersion(McpProtocol.MODERN_VERSION)` makes the client
speak 2026-07-28 over HTTP: `start()` runs `server/discover`, and
`getModernClient()` returns an `McpModernClient`, on the SDK's modern types, for
listings, tool calls, resource reads and prompts, with streamed progress,
`subscriptions/listen`, and cancellation by disposing the request. An
`input_required` result is answered by the `McpInputHandler` set with
`setInputHandler()` and the request retried, within `setMaxInputRounds()`;
without a handler, it is returned. Not yet supported for 2026-07-28: the client over
stdio. The SDK's modern API is a preview.
- Added experimental SDK-native gRPC hosting of unary, server-streaming,
client-streaming and bidirectional methods on a borrowed Restlet/Netty HTTP/2 listener.
Request messages are read as the application requests them, within the queue budget,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@

import io.modelcontextprotocol.client.McpAsyncClient;
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.json.McpJsonDefaults;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.TypeRef;
import io.modelcontextprotocol.spec.McpClientTransport;
import io.modelcontextprotocol.spec.McpSchema;
import java.io.IOException;
import java.io.UncheckedIOException;
import java.time.Duration;
import java.util.List;
import java.util.Map;
Expand All @@ -21,18 +25,35 @@
import java.util.function.Function;
import org.restlet.Client;
import org.restlet.Request;
import org.restlet.ext.mcp.internal.McpModernHttpTransport;
import org.restlet.ext.mcp.internal.StdioMcpClientTransport;
import reactor.core.publisher.Mono;

/** Lifecycle facade over the native SDK. Borrowed Restlet connectors are never stopped. */
/**
* Lifecycle facade over the native SDK. Borrowed Restlet connectors are never stopped.
*
* <p>By default it speaks MCP {@link McpProtocol#VERSION} through the SDK's client, {@link
* #getSdkClient()}. With {@link #setProtocolVersion(String)} set to {@link
* McpProtocol#MODERN_VERSION}, it speaks {@code 2026-07-28} over HTTP instead, experimentally:
* {@link #start()} discovers the server, and {@link #getModernClient()} makes the requests.
*/
public final class McpClientAdapter implements AutoCloseable {
/** The default number of {@code input_required} rounds answered for one request. */
public static final int DEFAULT_MAX_INPUT_ROUNDS = 8;

private final Client client;
private final String target;
private final org.restlet.util.StreamTransport stream;
private Duration requestTimeout = Duration.ofSeconds(30);
private int maxMessageBytes = McpProtocol.DEFAULT_MAX_MESSAGE_BYTES;
private int maxConcurrentCalls = McpProtocol.DEFAULT_MAX_CONCURRENT_CALLS;
private Consumer<Request> requestCustomizer = request -> {};
private String protocolVersion = McpProtocol.VERSION;
private io.modelcontextprotocol.modern.McpSchema.ClientCapabilities modernCapabilities =
io.modelcontextprotocol.modern.McpSchema.ClientCapabilities.NONE;
private McpInputHandler inputHandler;
private int maxInputRounds = DEFAULT_MAX_INPUT_ROUNDS;
private McpModernClient modern;
private McpAsyncClient sdk;
private McpClientTransport transport;
private boolean started;
Expand Down Expand Up @@ -102,12 +123,85 @@ public synchronized Consumer<Request> getRequestCustomizer() {
return requestCustomizer;
}

/**
* Sets the MCP revision spoken: {@link McpProtocol#VERSION}, the default, through the SDK's
* client, or {@link McpProtocol#MODERN_VERSION} through {@link #getModernClient()}, over HTTP
* only for now. Experimental for {@code 2026-07-28}.
*
* @param value The revision.
*/
public synchronized void setProtocolVersion(String value) {
requireConfigurable();
if (!McpProtocol.VERSION.equals(value) && !McpProtocol.MODERN_VERSION.equals(value)) {
throw new IllegalArgumentException("Unsupported MCP revision: " + value);
}
protocolVersion = value;
}

public synchronized String getProtocolVersion() {
return protocolVersion;
}

/**
* Sets the client capabilities sent with each {@link McpProtocol#MODERN_VERSION} request, none
* by default. A server asks only for the input they declare, such as elicitation.
*
* @param value The capabilities.
*/
public synchronized void setModernCapabilities(
io.modelcontextprotocol.modern.McpSchema.ClientCapabilities value) {
requireConfigurable();
modernCapabilities = Objects.requireNonNull(value, "value");
}

public synchronized io.modelcontextprotocol.modern.McpSchema.ClientCapabilities
getModernCapabilities() {
return modernCapabilities;
}

/**
* Sets what answers the {@code input_required} results of {@link McpProtocol#MODERN_VERSION}
* requests, after which the request is retried; without one, the result is returned.
*
* @param value The handler, or null for none.
*/
public synchronized void setInputHandler(McpInputHandler value) {
requireConfigurable();
inputHandler = value;
}

public synchronized McpInputHandler getInputHandler() {
return inputHandler;
}

/**
* Sets how many {@code input_required} results the input handler answers for one request,
* {@value #DEFAULT_MAX_INPUT_ROUNDS} by default; the next one is returned.
*
* @param value The number of rounds, zero or more.
*/
public synchronized void setMaxInputRounds(int value) {
requireConfigurable();
if (value < 0) {
throw new IllegalArgumentException("Input rounds must not be negative");
}
maxInputRounds = value;
}

public synchronized int getMaxInputRounds() {
return maxInputRounds;
}

public synchronized void start() {
if (started) {
return;
}
requireConfigurable();
configured = true;
if (McpProtocol.MODERN_VERSION.equals(protocolVersion)) {
startModern();
return;
}
transport = createTransport();
try {
sdk =
Expand All @@ -134,6 +228,69 @@ public synchronized void start() {
}
}

/** Starts a {@code 2026-07-28} client, and discovers the server's support of it. */
private void startModern() {
if (stream != null || target.startsWith("stdio:")) {
throw new UnsupportedOperationException(
"The MCP 2026-07-28 client supports HTTP only for now");
}
if (!client.isStarted()
|| client.getContext() == null
|| !"false"
.equalsIgnoreCase(
client.getContext()
.getParameters()
.getFirstValue("followRedirects", "true"))) {
throw new IllegalStateException(
"Borrowed MCP client must be started with followRedirects=false");
}
McpJsonMapper mapper = McpJsonDefaults.getMapper();
McpModernClient candidate =
new McpModernClient(
new McpModernHttpTransport(
client,
target,
mapper,
maxMessageBytes,
maxConcurrentCalls,
requestTimeout,
requestCustomizer),
mapper,
modernCapabilities,
inputHandler,
maxInputRounds);
try {
io.modelcontextprotocol.modern.McpSchema.DiscoverResult discovered =
candidate.discover().block(requestTimeout);
if (discovered == null
|| discovered.supportedVersions() == null
|| !discovered.supportedVersions().contains(McpProtocol.MODERN_VERSION)) {
throw new IllegalStateException("Peer does not support MCP 2026-07-28");
}
} catch (RuntimeException e) {
try {
candidate.close();
} catch (IOException cleanup) {
e.addSuppressed(cleanup);
}
throw e;
}
modern = candidate;
started = true;
}

/**
* The {@link McpProtocol#MODERN_VERSION} client, when that is the revision set.
*
* @return The client.
*/
public synchronized McpModernClient getModernClient() {
if (!started || modern == null) {
throw new IllegalStateException("MCP 2026-07-28 client is not started");
}
return modern;
}

private McpClientTransport createTransport() {
if (stream != null) {
return new StdioMcpClientTransport(
Expand Down Expand Up @@ -163,6 +320,10 @@ private McpClientTransport createTransport() {

/** Exposes native MCP operations, errors and call options without a parallel invocation API. */
public synchronized McpAsyncClient getSdkClient() {
if (modern != null) {
throw new IllegalStateException(
"This MCP client speaks 2026-07-28: use getModernClient()");
}
if (!started) {
throw new IllegalStateException("MCP client is not started");
}
Expand All @@ -184,6 +345,16 @@ public McpSchema.CallToolResult callTool(String name, Map<String, Object> argume
}

public synchronized void stop() {
if (modern != null) {
started = false;
try {
modern.close();
} catch (IOException e) {
throw new UncheckedIOException(e);
} finally {
modern = null;
}
}
if (sdk != null) {
started = false;
sdk.closeGracefully().block(requestTimeout);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Copyright 2005-2026 Qlik
*<p>
* The content of this file is subject to the terms of the Apache 2.0 open
* source license available at https://www.opensource.org/licenses/apache-2.0
*<p>
* Restlet is a registered trademark of QlikTech International AB.
*/
package org.restlet.ext.mcp;

import io.modelcontextprotocol.modern.McpSchema;
import java.util.Map;
import reactor.core.publisher.Mono;

/**
* Answers the input an MCP {@link McpProtocol#MODERN_VERSION} server asks for, through an {@code
* input_required} result, such as an elicitation: {@link McpModernClient} then retries the request
* with the responses and the result's request state. Experimental: the SDK's modern API is a
* preview.
*/
@FunctionalInterface
public interface McpInputHandler {

/**
* Answers the input requests of a result.
*
* @param required The result, with its input requests by key.
* @return The responses, by the keys of the input requests, such as {@code {"action": "accept",
* "content": {...}}} for an elicitation; or an empty {@code Mono} to return the result to
* the caller instead of retrying.
*/
Mono<Map<String, Object>> respond(McpSchema.InputRequiredResult required);
}
Loading
Loading