diff --git a/changes.md b/changes.md
index bf107ca16b..bc90ce64b1 100644
--- a/changes.md
+++ b/changes.md
@@ -103,8 +103,16 @@ Changes log
2026-07-28 clients only, for `input_required` results and streamed progress. The
interceptors wrap each 2026-07-28 request and its final result, the tool filters and
the call limit apply to both revisions, and `setRequestStateKey()` shares the key
- sealing `input_required` state between instances. Not yet supported for 2026-07-28:
- stdio, `subscriptions/listen`, and the MCP client. The SDK's modern API is a preview.
+ sealing `input_required` state between instances. `getModernResources()`,
+ `getModernResourceTemplates()` and `getModernPrompts()` add resources, templates and
+ prompts served to 2026-07-28 clients only; a URI, URI template or prompt name may only
+ be used once across both revisions' lists. `subscriptions/listen` streams carry the
+ changes published through `getChanges()`; they have their own limit,
+ `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.
- 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,
diff --git a/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/McpModernResource.java b/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/McpModernResource.java
new file mode 100644
index 0000000000..b384b95afb
--- /dev/null
+++ b/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/McpModernResource.java
@@ -0,0 +1,35 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * 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
+ *
+ * Restlet is a registered trademark of QlikTech International AB.
+ */
+package org.restlet.ext.mcp;
+
+import io.modelcontextprotocol.modern.McpSchema;
+import io.modelcontextprotocol.modern.server.feature.McpAsyncResource;
+import java.util.Objects;
+
+/**
+ * A resource served to MCP {@link McpProtocol#MODERN_VERSION} clients only, for {@link
+ * McpServerAdapter#getModernResources()}: its definition, listed, and its read handler, which may
+ * answer {@code input_required}. Experimental: the SDK's modern API is a preview.
+ *
+ * @param definition The resource, with its URI.
+ * @param resource The read handler.
+ */
+public record McpModernResource(McpSchema.Resource definition, McpAsyncResource resource) {
+
+ /**
+ * Creates a modern resource.
+ *
+ * @param definition The resource, with its URI.
+ * @param resource The read handler.
+ */
+ public McpModernResource {
+ Objects.requireNonNull(definition, "definition");
+ Objects.requireNonNull(resource, "resource");
+ }
+}
diff --git a/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/McpModernResourceTemplate.java b/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/McpModernResourceTemplate.java
new file mode 100644
index 0000000000..bc46823fb0
--- /dev/null
+++ b/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/McpModernResourceTemplate.java
@@ -0,0 +1,36 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * 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
+ *
+ * Restlet is a registered trademark of QlikTech International AB.
+ */
+package org.restlet.ext.mcp;
+
+import io.modelcontextprotocol.modern.McpSchema;
+import io.modelcontextprotocol.modern.server.feature.McpAsyncResource;
+import java.util.Objects;
+
+/**
+ * A resource template served to MCP {@link McpProtocol#MODERN_VERSION} clients only, for {@link
+ * McpServerAdapter#getModernResourceTemplates()}: its definition, listed, and the read handler of
+ * the URIs it matches. Experimental: the SDK's modern API is a preview.
+ *
+ * @param definition The template, with its URI template.
+ * @param resource The read handler of the matching URIs.
+ */
+public record McpModernResourceTemplate(
+ McpSchema.ResourceTemplate definition, McpAsyncResource resource) {
+
+ /**
+ * Creates a modern resource template.
+ *
+ * @param definition The template, with its URI template.
+ * @param resource The read handler of the matching URIs.
+ */
+ public McpModernResourceTemplate {
+ Objects.requireNonNull(definition, "definition");
+ Objects.requireNonNull(resource, "resource");
+ }
+}
diff --git a/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/McpProtocol.java b/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/McpProtocol.java
index 4a50a7148f..7faadfe182 100644
--- a/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/McpProtocol.java
+++ b/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/McpProtocol.java
@@ -26,5 +26,8 @@ public final class McpProtocol {
public static final int DEFAULT_MAX_MESSAGE_BYTES = 1024 * 1024;
public static final int DEFAULT_MAX_CONCURRENT_CALLS = 128;
+ /** The default number of open MCP 2026-07-28 {@code subscriptions/listen} streams. */
+ public static final int DEFAULT_MAX_LISTEN_STREAMS = 64;
+
private McpProtocol() {}
}
diff --git a/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/McpServerAdapter.java b/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/McpServerAdapter.java
index 3e989917e1..b3f979b33f 100644
--- a/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/McpServerAdapter.java
+++ b/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/McpServerAdapter.java
@@ -14,7 +14,10 @@
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.json.jackson2.JacksonMcpJsonMapper;
import io.modelcontextprotocol.modern.server.HmacRequestStateCodec;
+import io.modelcontextprotocol.modern.server.feature.McpAsyncPrompt;
import io.modelcontextprotocol.modern.server.feature.McpAsyncTool;
+import io.modelcontextprotocol.modern.server.feature.McpChangeBroadcaster;
+import io.modelcontextprotocol.modern.server.feature.McpChangeFeed;
import io.modelcontextprotocol.server.McpAsyncListFilter;
import io.modelcontextprotocol.server.McpServer;
import io.modelcontextprotocol.server.McpStatelessAsyncServer;
@@ -61,6 +64,9 @@
public final class McpServerAdapter extends Restlet {
private static final McpJsonMapper MAPPER = initMapper();
+ /** How long stopping waits for listen streams to end with their result. */
+ private static final Duration LISTEN_DRAIN = Duration.ofSeconds(2);
+
private static McpJsonMapper initMapper() {
McpJsonMapper mapper = McpJsonDefaults.getMapper();
if (mapper instanceof JacksonMcpJsonMapper jacksonMapper) {
@@ -82,7 +88,13 @@ private static McpJsonMapper initMapper() {
new CopyOnWriteArrayList<>();
private final List interceptors = new CopyOnWriteArrayList<>();
private final List modernTools = new CopyOnWriteArrayList<>();
+ private final List modernResources = new CopyOnWriteArrayList<>();
+ private final List modernResourceTemplates =
+ new CopyOnWriteArrayList<>();
+ private final List modernPrompts = new CopyOnWriteArrayList<>();
+ private final McpChangeFeed changes = new McpChangeFeed();
private byte[] requestStateKey;
+ private int maxListenStreams = McpProtocol.DEFAULT_MAX_LISTEN_STREAMS;
private final Set allowedOrigins = ConcurrentHashMap.newKeySet();
private McpSchema.Implementation serverInfo =
McpSchema.Implementation.builder("restlet", "3.0").build();
@@ -94,6 +106,7 @@ private static McpJsonMapper initMapper() {
private volatile McpStatelessAsyncServer sdkServer;
private volatile io.modelcontextprotocol.modern.server.McpServer modernServer;
private volatile McpModernExchange modernExchange;
+ private volatile McpWorkers listeners;
private volatile McpWorkers workers;
private volatile boolean accepting;
private volatile Sinks.One stopping;
@@ -147,6 +160,70 @@ public List getModernTools() {
return modernTools;
}
+ /**
+ * Resources served to MCP {@link McpProtocol#MODERN_VERSION} clients only, whose read may
+ * answer {@code input_required}. The resources of {@link #getResources()} are served to both
+ * revisions; a URI may only be used once across both lists. Experimental.
+ *
+ * @return The modern resources, modifiable until the adapter starts.
+ */
+ public List getModernResources() {
+ return modernResources;
+ }
+
+ /**
+ * Resource templates served to MCP {@link McpProtocol#MODERN_VERSION} clients only. The
+ * templates of {@link #getResourceTemplates()} are served to both revisions; a URI template may
+ * only be used once across both lists. Experimental.
+ *
+ * @return The modern resource templates, modifiable until the adapter starts.
+ */
+ public List getModernResourceTemplates() {
+ return modernResourceTemplates;
+ }
+
+ /**
+ * Prompts served to MCP {@link McpProtocol#MODERN_VERSION} clients only, on the SDK's modern
+ * types. The prompts of {@link #getPrompts()} are served to both revisions; a name may only be
+ * used once across both lists. Experimental.
+ *
+ * @return The modern prompts, modifiable until the adapter starts.
+ */
+ public List getModernPrompts() {
+ return modernPrompts;
+ }
+
+ /**
+ * Publishes changes to the MCP {@link McpProtocol#MODERN_VERSION} clients listening through
+ * {@code subscriptions/listen}: tools, prompts and resources list changes, and resource
+ * updates. A change nobody listens to is dropped. Experimental.
+ *
+ * @return The change broadcaster, usable at any time.
+ */
+ public McpChangeBroadcaster getChanges() {
+ return changes;
+ }
+
+ public synchronized int getMaxListenStreams() {
+ return maxListenStreams;
+ }
+
+ /**
+ * Sets how many {@code subscriptions/listen} streams may be open at once, {@value
+ * McpProtocol#DEFAULT_MAX_LISTEN_STREAMS} by default. Listen streams have no time limit and
+ * don't count against {@link #getMaxConcurrentCalls()}; they end when the adapter stops or the
+ * client goes away, and carry heartbeats.
+ *
+ * @param value The number of streams, positive.
+ */
+ public synchronized void setMaxListenStreams(int value) {
+ requireConfigurable();
+ if (value <= 0) {
+ throw new IllegalArgumentException("maxListenStreams must be positive");
+ }
+ maxListenStreams = value;
+ }
+
/**
* Sets the key sealing the {@code requestState} of {@code input_required} results, at least 32
* bytes. Without it, each adapter generates its own random key, so a retry must reach the
@@ -278,6 +355,7 @@ public List protocolVersions() {
.toolFilters(filters -> filters.addAll(toolFilters))
.build();
workers = new McpWorkers(maxConcurrentCalls, requestTimeout);
+ listeners = new McpWorkers(maxListenStreams, null, requestTimeout);
stopping = Sinks.one();
modernServer = modern;
modernExchange =
@@ -285,6 +363,7 @@ public List protocolVersions() {
modernServer,
MAPPER,
workers,
+ listeners,
requestTimeout,
maxMessageBytes,
(message, context, terminal) -> invoke(0, message, context, terminal),
@@ -316,7 +395,11 @@ private io.modelcontextprotocol.modern.server.McpServer modernServer() {
List.copyOf(resourceTemplates),
List.copyOf(prompts),
List.copyOf(toolFilters),
- List.copyOf(modernTools))));
+ List.copyOf(modernTools),
+ List.copyOf(modernResources),
+ List.copyOf(modernResourceTemplates),
+ List.copyOf(modernPrompts))))
+ .subscriptions(changes);
var codec =
HmacRequestStateCodec.builder()
.principalExtractor(McpServerAdapter::principal)
@@ -356,18 +439,26 @@ public synchronized void stop() throws Exception {
return;
}
accepting = false;
+ // Listen streams end with their result first; what remains is then cut short.
+ modernServer.closeGracefully();
+ try {
+ listeners.awaitIdle(
+ requestTimeout.compareTo(LISTEN_DRAIN) < 0 ? requestTimeout : LISTEN_DRAIN);
+ } catch (InterruptedException _) {
+ Thread.currentThread().interrupt();
+ }
stopping.tryEmitValue(Boolean.TRUE);
try {
- workers.close();
+ try {
+ workers.close();
+ } finally {
+ listeners.close();
+ }
} finally {
try {
sdkServer.closeGracefully().block(requestTimeout);
} finally {
- try {
- modernServer.closeGracefully();
- } finally {
- super.stop();
- }
+ super.stop();
}
}
}
diff --git a/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/StdioServerHelper.java b/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/StdioServerHelper.java
index fb082047ce..552cb3f47a 100644
--- a/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/StdioServerHelper.java
+++ b/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/StdioServerHelper.java
@@ -8,32 +8,84 @@
*/
package org.restlet.ext.mcp;
+import io.modelcontextprotocol.json.McpJsonDefaults;
+import io.modelcontextprotocol.json.McpJsonMapper;
+import io.modelcontextprotocol.json.TypeRef;
+import java.io.ByteArrayOutputStream;
import java.io.IOException;
+import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
import java.time.Duration;
+import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Semaphore;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.logging.Level;
import org.restlet.Request;
import org.restlet.Response;
+import org.restlet.Restlet;
import org.restlet.Server;
import org.restlet.data.MediaType;
import org.restlet.data.Method;
import org.restlet.data.Protocol;
import org.restlet.engine.connector.ServerHelper;
import org.restlet.ext.mcp.internal.McpMessages;
+import org.restlet.ext.mcp.internal.McpModernExchange;
import org.restlet.ext.mcp.internal.McpWorkers;
import org.restlet.representation.ByteArrayRepresentation;
+import org.restlet.representation.Representation;
+import org.restlet.routing.Filter;
/**
* Explicit newline-framed MCP STDIO connector. Every message traverses the Server's configured
* Restlet chain. The owner must supply a StreamTransport; stdout is exclusively protocol output.
+ *
+ * A message whose {@code _meta} names MCP {@link McpProtocol#MODERN_VERSION} goes to the
+ * adapter's 2026-07-28 server: the messages it streams, such as progress, are written as separate
+ * lines as they come, a {@code subscriptions/listen} stream stays open without time limit, and a
+ * {@code notifications/cancelled} naming an in-flight request ends it without a response.
*/
public final class StdioServerHelper extends ServerHelper {
+ private static final String CANCELLED = "notifications/cancelled";
+
+ /** Request attribute marking a request the client cancelled, which gets no response. */
+ private static final String CANCELLED_ATTRIBUTE = "org.restlet.ext.mcp.stdio.cancelled";
+
+ /** Room for the {@code data: } prefix of a streamed message's line. */
+ private static final int EVENT_LINE_OVERHEAD = 16;
+
+ private final McpJsonMapper mapper = McpJsonDefaults.getMapper();
private final Set requests = ConcurrentHashMap.newKeySet();
+
+ /** The in-flight 2026-07-28 requests, by id, which a client may cancel. */
+ private final Map inFlight = new ConcurrentHashMap<>();
+
+ /**
+ * Held while a frame is written, and while a request is marked cancelled: once a cancellation
+ * is marked, no frame of that request follows.
+ */
+ private final Object output = new Object();
+
private org.restlet.util.StreamTransport stream;
private McpWorkers workers;
+
+ /**
+ * The workers of 2026-07-28 requests, without limits of their own: the adapter's call and
+ * listen limits and request timeout apply, and what it refuses or cuts short is answered rather
+ * than failing the connector.
+ */
+ private McpWorkers modernWorkers;
+
+ /** The pending slots of 2026-07-28 requests, which bound their workers and output. */
+ private Semaphore pending;
+
private Thread reader;
private volatile boolean closed;
+
+ /** Whether the connector was stopped, after which failures are expected. */
+ private volatile boolean stopped;
+
private volatile Throwable failure;
public StdioServerHelper(Server server) {
@@ -55,6 +107,8 @@ public synchronized void start() throws IOException {
stream.setMaxOperationBytes(McpProtocol.DEFAULT_MAX_MESSAGE_BYTES + 1);
stream.start();
workers = new McpWorkers(McpProtocol.DEFAULT_MAX_CONCURRENT_CALLS, Duration.ofSeconds(30));
+ modernWorkers = new McpWorkers(Integer.MAX_VALUE, null, Duration.ofSeconds(30));
+ pending = new Semaphore(pendingLimit());
reader = Thread.ofVirtual().name("restlet-mcp-stdio-server").start(this::read);
}
@@ -66,9 +120,7 @@ private void read() {
McpMessages.readFrame(
stream, McpProtocol.DEFAULT_MAX_MESSAGE_BYTES))
!= null) {
- byte[] frame = bytes;
- workers.submit(() -> dispatch(frame), () -> {})
- .subscribe(ignored -> {}, this::fail);
+ accept(bytes);
}
if (!closed) {
fail(new IOException("MCP STDIO input ended"));
@@ -80,7 +132,212 @@ private void read() {
}
}
+ /** Builds the request of a frame, in reading order, and hands it to a worker. */
+ private void accept(byte[] bytes) {
+ Map, ?> envelope = envelope(bytes);
+ boolean modern = modern(envelope);
+ String method = envelope.get("method") instanceof String m ? m : null;
+ // A cancellation of an in-flight 2026-07-28 request, with or without _meta. A request
+ // naming this method, or a malformed envelope, goes on to be answered.
+ if (isCancellation(envelope, method) && (cancel(envelope) || modern)) {
+ return;
+ }
+ Request request = request(bytes, modern);
+ if (modern) {
+ submitModern(request, envelope, method);
+ } else {
+ workers.submit(() -> dispatch(request, null), () -> {})
+ .subscribe(ignored -> {}, this::fail);
+ }
+ }
+
+ /** Whether a message is a {@code notifications/cancelled} notification. */
+ private static boolean isCancellation(Map, ?> envelope, String method) {
+ return CANCELLED.equals(method)
+ && "2.0".equals(envelope.get("jsonrpc"))
+ && !envelope.containsKey("id");
+ }
+
+ /** The request of a frame, with the headers it would have over HTTP. */
+ private static Request request(byte[] bytes, boolean modern) {
+ Request request =
+ new Request(
+ Method.POST,
+ "stdio://localhost/mcp",
+ new ByteArrayRepresentation(bytes, MediaType.APPLICATION_JSON));
+ request.getHeaders()
+ .set(
+ McpProtocol.VERSION_HEADER,
+ modern ? McpProtocol.MODERN_VERSION : McpProtocol.VERSION);
+ request.getHeaders().set("Accept", "application/json, text/event-stream");
+ return request;
+ }
+
+ /**
+ * Hands a 2026-07-28 request to a worker if one of the pending slots is free, else refuses it
+ * at once: the reader keeps reading, cancellations included, and a client that stops reading
+ * the output stops being read in turn, when the refusal cannot be written.
+ */
+ private void submitModern(Request request, Map, ?> envelope, String method) {
+ if (method != null) {
+ request.getHeaders().set("Mcp-Method", method);
+ }
+ request.getAttributes().put(McpModernExchange.STDIO_ATTRIBUTE, Boolean.TRUE);
+ boolean answered = envelope.containsKey("id");
+ Object id = validId(envelope.get("id"));
+ if (!pending.tryAcquire()) {
+ refuse(request, answered, id, new IOException("Too many pending MCP STDIO requests"));
+ return;
+ }
+ Object key = id == null ? null : key(id);
+ if (key != null && inFlight.putIfAbsent(key, request) != null) {
+ key = null; // A duplicate id: only the first may be cancelled.
+ }
+ Object registered = key;
+ AtomicBoolean held = new AtomicBoolean(true);
+ // Freed once the response, or the error answering a failure, is written.
+ Runnable done =
+ () -> {
+ if (registered != null) {
+ inFlight.remove(registered, request);
+ }
+ if (held.compareAndSet(true, false)) {
+ pending.release();
+ }
+ };
+ modernWorkers
+ .submit(() -> dispatch(request, id), () -> {})
+ .subscribe(
+ null,
+ error -> {
+ try {
+ if (!closed && !isCancelled(request)) {
+ // Refused, too large or broken: answered, the connector stays.
+ refuse(request, answered, id, error);
+ }
+ } finally {
+ done.run();
+ }
+ },
+ done);
+ }
+
+ /** A request id as JSON-RPC allows it, a string or a number; else null. */
+ private static Object validId(Object id) {
+ return id instanceof String || id instanceof Number ? id : null;
+ }
+
+ /**
+ * How many 2026-07-28 requests may be pending at once, writing or waiting to write: the open
+ * listen streams the adapter allows, and as many other requests as the default call limit.
+ */
+ private int pendingLimit() {
+ Restlet next = getHelped() == null ? null : getHelped().getNext();
+ while (next instanceof Filter filter) {
+ next = filter.getNext();
+ }
+ int listens =
+ next instanceof McpServerAdapter adapter
+ ? adapter.getMaxListenStreams()
+ : McpProtocol.DEFAULT_MAX_LISTEN_STREAMS;
+ return listens + McpProtocol.DEFAULT_MAX_CONCURRENT_CALLS;
+ }
+
+ /** The JSON object of a frame, or an empty map when it isn't one; the adapter rejects it. */
+ private Map, ?> envelope(byte[] bytes) {
+ try {
+ return mapper.readValue(McpMessages.utf8(bytes), new TypeRef() {})
+ instanceof Map, ?> map
+ ? map
+ : Map.of();
+ } catch (IOException | RuntimeException _) {
+ return Map.of();
+ }
+ }
+
+ /** Whether a message's {@code _meta} names the 2026-07-28 revision. */
+ private static boolean modern(Map, ?> envelope) {
+ return envelope.get("params") instanceof Map, ?> params
+ && params.get("_meta") instanceof Map, ?> meta
+ && McpProtocol.MODERN_VERSION.equals(
+ meta.get(
+ io.modelcontextprotocol.modern.McpSchema.MetaKeys
+ .PROTOCOL_VERSION));
+ }
+
+ /** A request id comparable across its JSON integer forms, such as 1 and 1.0. */
+ private static Object key(Object id) {
+ return id instanceof Number number && number.doubleValue() == number.longValue()
+ ? (Object) number.longValue()
+ : id;
+ }
+
+ /**
+ * Ends the in-flight request a {@code notifications/cancelled} names, if any.
+ *
+ * @return True if it named one.
+ */
+ private boolean cancel(Map, ?> envelope) {
+ Object id =
+ envelope.get("params") instanceof Map, ?> params ? params.get("requestId") : null;
+ Request request = id == null ? null : inFlight.remove(key(id));
+ if (request == null) {
+ return false;
+ }
+ synchronized (output) {
+ request.getAttributes().put(CANCELLED_ATTRIBUTE, Boolean.TRUE);
+ }
+ request.abort();
+ return true;
+ }
+
+ private static boolean isCancelled(Request request) {
+ return Boolean.TRUE.equals(request.getAttributes().get(CANCELLED_ATTRIBUTE));
+ }
+
+ /**
+ * Writes a frame of a request, unless the client cancelled it.
+ *
+ * @param request The request answered, or null for none.
+ * @param frame The frame, without its newline.
+ */
+ private void emit(Request request, byte[] frame) throws IOException {
+ synchronized (output) {
+ if (request == null || !isCancelled(request)) {
+ McpMessages.writeFrame(stream, frame);
+ }
+ }
+ }
+
+ /**
+ * Answers a 2026-07-28 request refused, cut short or failed, unless it was cancelled.
+ *
+ * @param request The request.
+ * @param answered Whether it is a request, which gets an answer, rather than a notification.
+ * @param id Its valid id, or null to answer with a null id.
+ * @param cause Why it is refused.
+ */
+ private void refuse(Request request, boolean answered, Object id, Throwable cause) {
+ getLogger().log(Level.FINE, "MCP STDIO request refused or failed", cause);
+ if (!answered) {
+ return;
+ }
+ try {
+ emit(
+ request,
+ McpMessages.encode(
+ mapper,
+ McpMessages.error(id, -32603, "MCP exchange unavailable"),
+ McpProtocol.DEFAULT_MAX_MESSAGE_BYTES));
+ } catch (IOException | RuntimeException e) {
+ fail(e);
+ }
+ }
+
private void fail(Throwable cause) {
+ if (stopped) {
+ return; // Stopping cuts exchanges short: not a failure.
+ }
failure = cause;
closed = true;
getLogger().log(Level.WARNING, "MCP STDIO server input/exchange failed", cause);
@@ -93,17 +350,17 @@ private void fail(Throwable cause) {
}
}
- private void dispatch(byte[] bytes) throws IOException {
+ /**
+ * Handles a request and writes its response.
+ *
+ * @param request The request.
+ * @param id The id of a 2026-07-28 request, given to an error the adapter answered without it,
+ * having refused the request unread; or null.
+ */
+ private void dispatch(Request request, Object id) throws IOException {
if (closed) {
throw new IOException("MCP STDIO server is closed", failure);
}
- Request request =
- new Request(
- Method.POST,
- "stdio://localhost/mcp",
- new ByteArrayRepresentation(bytes, MediaType.APPLICATION_JSON));
- request.getHeaders().set(McpProtocol.VERSION_HEADER, McpProtocol.VERSION);
- request.getHeaders().set("Accept", "application/json, text/event-stream");
Response response = new Response(request);
requests.add(request);
try {
@@ -112,14 +369,27 @@ private void dispatch(byte[] bytes) throws IOException {
throw new IOException("MCP STDIO server is closed", failure);
}
handle(request, response);
- if (response.getEntity() != null) {
- if (!MediaType.APPLICATION_JSON.isCompatible(response.getEntity().getMediaType())) {
+ Representation entity = response.getEntity();
+ if (isCancelled(request)) {
+ return; // A cancelled request gets no response.
+ }
+ if (entity != null) {
+ if (MediaType.TEXT_EVENT_STREAM.isCompatible(entity.getMediaType())) {
+ try (var frames = new EventFrames(request)) {
+ entity.write(frames);
+ }
+ } else if (MediaType.APPLICATION_JSON.isCompatible(entity.getMediaType())) {
+ try (var input = entity.getStream()) {
+ emit(
+ request,
+ withId(
+ McpMessages.read(
+ input, McpProtocol.DEFAULT_MAX_MESSAGE_BYTES),
+ id));
+ }
+ } else {
throw new IOException("MCP STDIO handler returned a non-JSON entity");
}
- try (var input = response.getEntity().getStream()) {
- McpMessages.writeFrame(
- stream, McpMessages.read(input, McpProtocol.DEFAULT_MAX_MESSAGE_BYTES));
- }
} else if (!response.getStatus().isSuccess()) {
throw new IOException(
"MCP STDIO handler rejected exchange: " + response.getStatus());
@@ -132,6 +402,99 @@ private void dispatch(byte[] bytes) throws IOException {
}
}
+ /** A reply, given the request id when it is an error without one. */
+ private byte[] withId(byte[] reply, Object id) throws IOException {
+ if (id == null) {
+ return reply;
+ }
+ Map, ?> envelope = envelope(reply);
+ if (envelope.get("id") != null || !(envelope.get("error") instanceof Map, ?> error)) {
+ return reply;
+ }
+ Map answered =
+ McpMessages.error(
+ id,
+ error.get("code") instanceof Number code ? code.intValue() : -32603,
+ String.valueOf(error.get("message")));
+ return McpMessages.encode(mapper, answered, McpProtocol.DEFAULT_MAX_MESSAGE_BYTES);
+ }
+
+ /**
+ * Writes the data of each event of a server-sent event stream as one line, as it ends.
+ * Comments, such as heartbeats, are dropped, and so is what follows a cancellation.
+ */
+ private final class EventFrames extends OutputStream {
+ private final Request request;
+ private final ByteArrayOutputStream line = new ByteArrayOutputStream();
+ private final ByteArrayOutputStream data = new ByteArrayOutputStream();
+ private boolean hasData;
+
+ /**
+ * @param request The request whose stream this is, which may be cancelled meanwhile.
+ */
+ private EventFrames(Request request) {
+ this.request = request;
+ }
+
+ @Override
+ public void write(int b) throws IOException {
+ if (b == '\n') {
+ endLine();
+ } else if (line.size() > McpProtocol.DEFAULT_MAX_MESSAGE_BYTES + EVENT_LINE_OVERHEAD) {
+ throw new IOException("MCP streamed message exceeds the message size");
+ } else {
+ line.write(b);
+ }
+ }
+
+ @Override
+ public void write(byte[] bytes, int offset, int length) throws IOException {
+ for (int i = offset; i < offset + length; i++) {
+ write(bytes[i]);
+ }
+ }
+
+ private void endLine() throws IOException {
+ byte[] bytes = line.toByteArray();
+ line.reset();
+ int length = bytes.length;
+ if (length > 0 && bytes[length - 1] == '\r') {
+ length--;
+ }
+ if (length == 0) {
+ endEvent();
+ return;
+ }
+ String text = new String(bytes, 0, length, StandardCharsets.UTF_8);
+ if (text.startsWith("data:")) {
+ addData(text.substring(text.startsWith("data: ") ? 6 : 5));
+ }
+ }
+
+ /** Writes the event that ends, if it has data; a cancelled request's is dropped. */
+ private void endEvent() throws IOException {
+ if (hasData) {
+ emit(request, data.toByteArray());
+ }
+ data.reset();
+ hasData = false;
+ }
+
+ /** Adds a data line to the event, joined by a newline, within the message size. */
+ private void addData(String value) throws IOException {
+ byte[] bytes = value.getBytes(StandardCharsets.UTF_8);
+ int separator = hasData ? 1 : 0;
+ if (data.size() + separator + bytes.length > McpProtocol.DEFAULT_MAX_MESSAGE_BYTES) {
+ throw new IOException("MCP streamed message exceeds the message size");
+ }
+ if (hasData) {
+ data.write('\n');
+ }
+ data.writeBytes(bytes);
+ hasData = true;
+ }
+ }
+
/**
* Terminal input/worker failure, if any; connector owners should monitor this for subprocesses.
*/
@@ -141,38 +504,65 @@ public Throwable getFailure() {
@Override
public synchronized void stop() throws Exception {
+ stopped = true;
closed = true;
requests.forEach(Request::abort);
- Exception error = null;
- try {
- if (stream != null) {
- stream.stop();
- }
- } catch (IOException e) {
- error = e;
+ Exception error =
+ attempt(
+ null,
+ () -> {
+ if (stream != null) {
+ stream.stop();
+ }
+ });
+ for (McpWorkers pool : new McpWorkers[] {workers, modernWorkers}) {
+ error =
+ attempt(
+ error,
+ () -> {
+ if (pool != null) {
+ pool.close();
+ }
+ });
+ }
+ boolean joined = reader == null || reader.join(Duration.ofSeconds(30));
+ error =
+ attempt(
+ error,
+ () -> {
+ if (!joined) {
+ throw new IOException("MCP STDIO server reader shutdown timed out");
+ }
+ });
+ super.stop();
+ if (error != null) {
+ throw error;
}
+ }
+
+ /** A shutdown step. */
+ @FunctionalInterface
+ private interface Step {
+ void run() throws IOException;
+ }
+
+ /**
+ * Runs a shutdown step, keeping the first error and suppressing the next ones in it.
+ *
+ * @param error The error so far, or null.
+ * @param step The step.
+ * @return The error so far, after the step.
+ */
+ private static Exception attempt(Exception error, Step step) {
try {
- if (workers != null) {
- workers.close();
- }
+ step.run();
+ return error;
} catch (IOException e) {
if (error == null) {
- error = e;
- } else {
- error.addSuppressed(e);
+ return e;
}
- }
- if (reader != null && !reader.join(Duration.ofSeconds(30))) {
- IOException join = new IOException("MCP STDIO server reader shutdown timed out");
- if (error == null) {
- error = join;
- } else {
- error.addSuppressed(join);
- }
- }
- super.stop();
- if (error != null) {
- throw error;
+ error.addSuppressed(e);
+ return error;
}
}
}
diff --git a/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/internal/McpModernExchange.java b/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/internal/McpModernExchange.java
index 3bb83ae1e6..1aa4941f92 100644
--- a/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/internal/McpModernExchange.java
+++ b/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/internal/McpModernExchange.java
@@ -87,6 +87,13 @@ Mono run(
McpInterceptor.Chain terminal);
}
+ /**
+ * Request attribute marking an exchange that came over stdio, which has no headers: the header
+ * checks are skipped and the {@code Mcp-Param} headers count as not mirrored, as on the SDK's
+ * own stdio transport.
+ */
+ public static final String STDIO_ATTRIBUTE = "org.restlet.ext.mcp.stdio";
+
/** Streamed messages a slow client may leave pending before the SDK is held back. */
private static final int QUEUE_CAPACITY = 64;
@@ -106,6 +113,7 @@ Mono run(
private final McpServer sdk;
private final McpJsonMapper mapper;
private final McpWorkers workers;
+ private final McpWorkers listeners;
private final Duration timeout;
private final int maxMessageBytes;
private final Interceptors interceptors;
@@ -117,6 +125,7 @@ Mono run(
* @param sdk The SDK's modern server.
* @param mapper The JSON mapper.
* @param workers The adapter's admission workers.
+ * @param listeners The workers of {@code subscriptions/listen} streams, without time limit.
* @param timeout The request timeout.
* @param maxMessageBytes The largest request or response message.
* @param interceptors The adapter's interceptor chain.
@@ -126,6 +135,7 @@ public McpModernExchange(
McpServer sdk,
McpJsonMapper mapper,
McpWorkers workers,
+ McpWorkers listeners,
Duration timeout,
int maxMessageBytes,
Interceptors interceptors,
@@ -133,6 +143,7 @@ public McpModernExchange(
this.sdk = sdk;
this.mapper = mapper;
this.workers = workers;
+ this.listeners = listeners;
this.timeout = timeout;
this.maxMessageBytes = maxMessageBytes;
this.interceptors = interceptors;
@@ -147,8 +158,13 @@ public McpModernExchange(
* @param response The response.
*/
public void handle(Request request, Response response) {
+ // Recognised before the body is read, which must then name the same method.
+ boolean listen = isListen(request.getHeaders().getFirstValue("Mcp-Method", true));
var exchange =
- new Exchange(request, new McpRequestContext(request, McpProtocol.MODERN_VERSION));
+ new Exchange(
+ request,
+ new McpRequestContext(request, McpProtocol.MODERN_VERSION),
+ listen);
Mono stopped =
stopping.get()
.flatMap(ignored -> Mono.error(new IOException("MCP server stopped")));
@@ -162,7 +178,8 @@ public void handle(Request request, Response response) {
Mono.firstWithSignal(
stopped,
disconnected,
- workers.submit(exchange::run, () -> request.getEntity().release()))
+ (listen ? listeners : workers)
+ .submit(exchange::run, () -> request.getEntity().release()))
.subscribe(null, exchange::fail, exchange::end);
Reply reply;
try {
@@ -180,6 +197,16 @@ public void handle(Request request, Response response) {
reply.apply(response, exchange, running, heartbeat());
}
+ /**
+ * Whether a method opens a {@code subscriptions/listen} stream, which has no time limit.
+ *
+ * @param method The method, or null.
+ * @return True for {@code subscriptions/listen}.
+ */
+ public static boolean isListen(String method) {
+ return io.modelcontextprotocol.modern.McpSchema.METHOD_SUBSCRIPTIONS_LISTEN.equals(method);
+ }
+
/** Comment heartbeats on a stream, as the SDK's servlet transport sends: at most 30 s apart. */
private Duration heartbeat() {
Duration half = timeout.dividedBy(2);
@@ -209,9 +236,22 @@ private final class Exchange {
/** The id of the request, once read; answers a stream cut short. */
private volatile Object requestId;
- private Exchange(Request request, McpRequestContext context) {
+ /** Whether this is a {@code subscriptions/listen} stream, which has no time limit. */
+ private final boolean listen;
+
+ /** Whether this exchange came over stdio, without headers. */
+ private final boolean stdio;
+
+ private Exchange(Request request, McpRequestContext context, boolean listen) {
this.request = request;
this.context = context;
+ this.listen = listen;
+ this.stdio = Boolean.TRUE.equals(request.getAttributes().get(STDIO_ATTRIBUTE));
+ }
+
+ /** Waits for a chain's response, within the request timeout unless listening. */
+ private Optional await(Mono chain) {
+ return listen ? chain.blockOptional() : chain.blockOptional(timeout);
}
/** The SDK transport context for the request context an interceptor forwarded. */
@@ -266,19 +306,18 @@ private void notification(JsonRpc.JSONRPCNotification notification) {
private void call(JsonRpc.JSONRPCRequest rpc) throws InterruptedException {
requestId = rpc.id();
- String mismatch = mismatch(rpc);
+ String mismatch = stdio ? null : mismatch(rpc);
if (mismatch != null) {
head.complete(error(rpc.id(), ErrorCodes.HEADER_MISMATCH, mismatch));
return;
}
Optional last =
- interceptors
- .run(
+ await(
+ interceptors.run(
mapper.convertValue(rpc, McpSchema.JSONRPCRequest.class),
context,
(next, nextContext) ->
- Mono.fromCallable(() -> terminal(next, nextContext)))
- .blockOptional(timeout);
+ Mono.fromCallable(() -> terminal(next, nextContext))));
if (last.isEmpty()) {
// An interceptor answered nothing: accepted, as on the 2025-11-25 path, unless the
// stream already started, which must still answer the request.
@@ -314,9 +353,14 @@ private McpSchema.JSONRPCResponse terminal(
sdkContext.putAll(
Map.of(
McpParamHeaders.TRANSPORT_CONTEXT_KEY,
- McpHeaderDecoder.paramHeaders(headers())));
+ stdio
+ ? McpParamHeaders.NOT_MIRRORED
+ : McpHeaderDecoder.paramHeaders(headers())));
}
- McpTransportResponse reply = sdk.handleBlocking(sdkContext, rpc).block(timeout);
+ McpTransportResponse reply =
+ listen
+ ? sdk.handle(sdkContext, rpc).block(timeout)
+ : sdk.handleBlocking(sdkContext, rpc).block(timeout);
if (reply instanceof McpTransportResponse.Result result) {
return classic(result.response());
}
diff --git a/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/internal/McpModernFeatures.java b/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/internal/McpModernFeatures.java
index 3df8f33269..0ed6612dfb 100644
--- a/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/internal/McpModernFeatures.java
+++ b/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/internal/McpModernFeatures.java
@@ -8,6 +8,7 @@
*/
package org.restlet.ext.mcp.internal;
+import io.modelcontextprotocol.common.McpTransportContext;
import io.modelcontextprotocol.json.McpJsonMapper;
import io.modelcontextprotocol.modern.McpSchema;
import io.modelcontextprotocol.modern.server.McpAsyncResponse;
@@ -33,6 +34,9 @@
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
+import java.util.function.BiFunction;
+import org.restlet.ext.mcp.McpModernResource;
+import org.restlet.ext.mcp.McpModernResourceTemplate;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
@@ -40,8 +44,8 @@
* The features of the MCP {@code 2026-07-28} server, built from the adapter's registrations. The
* {@code 2025-11-25} specifications are bridged: their definitions and results are converted
* between the two SDK schemas through JSON, and their handlers receive the same transport context
- * as on the stateless server. Modern tools are served as registered. List filters apply to every
- * listed tool.
+ * as on the stateless server. Modern registrations are served as registered. List filters apply to
+ * every listed tool. A name, URI or URI template may only be registered once across both kinds.
*/
public final class McpModernFeatures {
@@ -56,6 +60,9 @@ private McpModernFeatures() {}
* @param prompts The {@code 2025-11-25} prompts.
* @param toolFilters The tool list filters.
* @param modernTools The {@code 2026-07-28} tools.
+ * @param modernResources The {@code 2026-07-28} resources.
+ * @param modernResourceTemplates The {@code 2026-07-28} resource templates.
+ * @param modernPrompts The {@code 2026-07-28} prompts.
*/
public record Registrations(
List tools,
@@ -63,7 +70,10 @@ public record Registrations(
List resourceTemplates,
List prompts,
List> toolFilters,
- List modernTools) {}
+ List modernTools,
+ List modernResources,
+ List modernResourceTemplates,
+ List modernPrompts) {}
/**
* Builds the features for registrations, one per kind present.
@@ -71,18 +81,21 @@ public record Registrations(
* @param mapper The JSON mapper converting between the two schemas.
* @param registrations The adapter's registrations.
* @return The features.
- * @throws IllegalArgumentException If two tools share a name.
+ * @throws IllegalArgumentException If a name, URI or URI template is registered twice.
*/
public static List of(McpJsonMapper mapper, Registrations registrations) {
List features = new ArrayList<>();
if (!registrations.tools().isEmpty() || !registrations.modernTools().isEmpty()) {
features.add(ToolsFeature.ofAsync(new Tools(mapper, registrations)));
}
- if (!registrations.resources().isEmpty() || !registrations.resourceTemplates().isEmpty()) {
+ if (!registrations.resources().isEmpty()
+ || !registrations.resourceTemplates().isEmpty()
+ || !registrations.modernResources().isEmpty()
+ || !registrations.modernResourceTemplates().isEmpty()) {
features.add(ResourcesFeature.ofAsync(new Resources(mapper, registrations)));
}
- if (!registrations.prompts().isEmpty()) {
- features.add(PromptsFeature.ofAsync(new Prompts(mapper, registrations.prompts())));
+ if (!registrations.prompts().isEmpty() || !registrations.modernPrompts().isEmpty()) {
+ features.add(PromptsFeature.ofAsync(new Prompts(mapper, registrations)));
}
return features;
}
@@ -154,66 +167,70 @@ public Mono find(McpRequestContext ctx, String name) {
}
}
- /** The bridged resources: exact URIs first, then templates, in registration order. */
+ /** A URI template with its matcher and read handler. */
+ private record Template(
+ McpSchema.ResourceTemplate definition,
+ DefaultMcpUriTemplateManager matcher,
+ McpAsyncResource resource) {}
+
+ /**
+ * The resources, bridged and modern: exact URIs first, then templates, in registration order.
+ * Subscriptions are supported: the adapter publishes resource updates through its change feed.
+ */
private static final class Resources implements McpAsyncResourceRepository {
private final McpJsonMapper mapper;
- private final List exact;
- private final List templates;
+ private final List listed = new ArrayList<>();
+ private final Map byUri = new LinkedHashMap<>();
+ private final List templates = new ArrayList<>();
private Resources(McpJsonMapper mapper, Registrations registrations) {
this.mapper = mapper;
- this.exact = List.copyOf(registrations.resources());
- this.templates = List.copyOf(registrations.resourceTemplates());
- }
-
- @Override
- public Mono list(McpRequestContext ctx, String cursor) {
- return Mono.fromSupplier(
- () ->
- ResourcesPage.of(
- exact.stream()
- .map(
- spec ->
- mapper.convertValue(
- spec.resource(),
- McpSchema.Resource.class))
- .toList()));
+ for (McpStatelessServerFeatures.AsyncResourceSpecification spec :
+ registrations.resources()) {
+ add(
+ mapper.convertValue(spec.resource(), McpSchema.Resource.class),
+ read(spec.readHandler()));
+ }
+ for (McpModernResource resource : registrations.modernResources()) {
+ add(resource.definition(), resource.resource());
+ }
+ for (McpStatelessServerFeatures.AsyncResourceTemplateSpecification spec :
+ registrations.resourceTemplates()) {
+ add(
+ mapper.convertValue(
+ spec.resourceTemplate(), McpSchema.ResourceTemplate.class),
+ read(spec.readHandler()));
+ }
+ for (McpModernResourceTemplate template : registrations.modernResourceTemplates()) {
+ add(template.definition(), template.resource());
+ }
}
- @Override
- public Mono listTemplates(McpRequestContext ctx, String cursor) {
- return Mono.fromSupplier(
- () ->
- ResourceTemplatesPage.of(
- templates.stream()
- .map(
- spec ->
- mapper.convertValue(
- spec.resourceTemplate(),
- McpSchema.ResourceTemplate
- .class))
- .toList()));
+ private void add(McpSchema.Resource definition, McpAsyncResource resource) {
+ if (byUri.putIfAbsent(definition.uri(), resource) != null) {
+ throw new IllegalArgumentException(
+ "Duplicate MCP resource URI: " + definition.uri());
+ }
+ listed.add(definition);
}
- @Override
- public Mono find(McpRequestContext ctx, String uri) {
- for (McpStatelessServerFeatures.AsyncResourceSpecification spec : exact) {
- if (spec.resource().uri().equals(uri)) {
- return Mono.just(read(spec.readHandler()));
+ private void add(McpSchema.ResourceTemplate definition, McpAsyncResource resource) {
+ for (Template template : templates) {
+ if (template.definition().uriTemplate().equals(definition.uriTemplate())) {
+ throw new IllegalArgumentException(
+ "Duplicate MCP resource template: " + definition.uriTemplate());
}
}
- for (McpStatelessServerFeatures.AsyncResourceTemplateSpecification spec : templates) {
- if (new DefaultMcpUriTemplateManager(spec.resourceTemplate().uriTemplate())
- .matches(uri)) {
- return Mono.just(read(spec.readHandler()));
- }
- }
- return Mono.empty();
+ templates.add(
+ new Template(
+ definition,
+ new DefaultMcpUriTemplateManager(definition.uriTemplate()),
+ resource));
}
private McpAsyncResource read(
- java.util.function.BiFunction<
- io.modelcontextprotocol.common.McpTransportContext,
+ BiFunction<
+ McpTransportContext,
io.modelcontextprotocol.spec.McpSchema.ReadResourceRequest,
Mono>
handler) {
@@ -235,65 +252,93 @@ private McpAsyncResource read(
.ReadResourceResult
.class))));
}
+
+ @Override
+ public Mono list(McpRequestContext ctx, String cursor) {
+ return Mono.just(ResourcesPage.of(List.copyOf(listed)));
+ }
+
+ @Override
+ public Mono listTemplates(McpRequestContext ctx, String cursor) {
+ return Mono.just(
+ ResourceTemplatesPage.of(
+ templates.stream().map(Template::definition).toList()));
+ }
+
+ @Override
+ public Mono find(McpRequestContext ctx, String uri) {
+ McpAsyncResource exact = byUri.get(uri);
+ if (exact != null) {
+ return Mono.just(exact);
+ }
+ for (Template template : templates) {
+ if (template.matcher().matches(uri)) {
+ return Mono.just(template.resource());
+ }
+ }
+ return Mono.empty();
+ }
+
+ @Override
+ public boolean supportsSubscribe() {
+ return true;
+ }
}
- /** The bridged prompts, indexed by name. */
+ /** The prompts, bridged and modern, indexed by name. */
private static final class Prompts implements McpAsyncPromptRepository {
- private final McpJsonMapper mapper;
- private final Map byName =
- new LinkedHashMap<>();
+ private final Map byName = new LinkedHashMap<>();
- private Prompts(
- McpJsonMapper mapper,
- List prompts) {
- this.mapper = mapper;
- for (McpStatelessServerFeatures.AsyncPromptSpecification spec : prompts) {
- byName.put(spec.prompt().name(), spec);
+ private Prompts(McpJsonMapper mapper, Registrations registrations) {
+ for (McpStatelessServerFeatures.AsyncPromptSpecification spec :
+ registrations.prompts()) {
+ add(bridge(mapper, spec));
+ }
+ for (McpAsyncPrompt prompt : registrations.modernPrompts()) {
+ add(prompt);
+ }
+ }
+
+ private static McpAsyncPrompt bridge(
+ McpJsonMapper mapper, McpStatelessServerFeatures.AsyncPromptSpecification spec) {
+ return McpAsyncPrompt.of(
+ mapper.convertValue(spec.prompt(), McpSchema.Prompt.class),
+ (ctx, request) ->
+ spec.promptHandler()
+ .apply(
+ ctx.transportContext(),
+ mapper.convertValue(
+ request,
+ io.modelcontextprotocol.spec.McpSchema
+ .GetPromptRequest.class))
+ .map(
+ result ->
+ McpAsyncResponse
+ .result(
+ mapper.convertValue(
+ result,
+ McpSchema
+ .GetPromptResult
+ .class))));
+ }
+
+ private void add(McpAsyncPrompt prompt) {
+ if (byName.putIfAbsent(prompt.definition().name(), prompt) != null) {
+ throw new IllegalArgumentException(
+ "Duplicate MCP prompt name: " + prompt.definition().name());
}
}
@Override
public Mono list(McpRequestContext ctx, String cursor) {
- return Mono.fromSupplier(
- () ->
- PromptsPage.of(
- byName.values().stream()
- .map(
- spec ->
- mapper.convertValue(
- spec.prompt(),
- McpSchema.Prompt.class))
- .toList()));
+ return Mono.just(
+ PromptsPage.of(
+ byName.values().stream().map(McpAsyncPrompt::definition).toList()));
}
@Override
public Mono find(McpRequestContext ctx, String name) {
- McpStatelessServerFeatures.AsyncPromptSpecification spec = byName.get(name);
- if (spec == null) {
- return Mono.empty();
- }
- return Mono.just(
- McpAsyncPrompt.of(
- mapper.convertValue(spec.prompt(), McpSchema.Prompt.class),
- (ctx2, request) ->
- spec.promptHandler()
- .apply(
- ctx2.transportContext(),
- mapper.convertValue(
- request,
- io.modelcontextprotocol.spec.McpSchema
- .GetPromptRequest.class))
- .map(
- result ->
- McpAsyncResponse
- .
- result(
- mapper
- .convertValue(
- result,
- McpSchema
- .GetPromptResult
- .class)))));
+ return Mono.justOrEmpty(byName.get(name));
}
}
}
diff --git a/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/internal/McpWorkers.java b/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/internal/McpWorkers.java
index c515f26782..cf74b4d8f7 100644
--- a/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/internal/McpWorkers.java
+++ b/org.restlet.ext.mcp/src/main/java/org/restlet/ext/mcp/internal/McpWorkers.java
@@ -10,6 +10,7 @@
import java.io.IOException;
import java.time.Duration;
+import java.util.Objects;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
@@ -17,20 +18,44 @@
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicReference;
import reactor.core.publisher.Mono;
+import reactor.core.publisher.MonoSink;
/** Finite admission before submission, without a task queue or shared global scheduler. */
public final class McpWorkers {
private final ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
private final Semaphore admission;
+ private final int maxCalls;
+
+ /** The time limit of each call, or null for none. */
private final Duration timeout;
+
+ /** How long {@link #close()} waits for the calls to end. */
+ private final Duration closeTimeout;
+
private boolean closed;
public McpWorkers(int maxCalls, Duration timeout) {
- if (maxCalls <= 0 || timeout.isZero() || timeout.isNegative()) {
- throw new IllegalArgumentException("Positive call limit and timeout required");
+ this(maxCalls, Objects.requireNonNull(timeout, "timeout"), timeout);
+ }
+
+ /**
+ * Creates workers whose calls may have no time limit, such as long-lived streams.
+ *
+ * @param maxCalls The number of concurrent calls.
+ * @param timeout The time limit of each call, or null for none.
+ * @param closeTimeout How long {@link #close()} waits for the calls to end.
+ */
+ public McpWorkers(int maxCalls, Duration timeout, Duration closeTimeout) {
+ if (maxCalls <= 0
+ || (timeout != null && (timeout.isZero() || timeout.isNegative()))
+ || closeTimeout.isZero()
+ || closeTimeout.isNegative()) {
+ throw new IllegalArgumentException("Positive call limit and timeouts required");
}
this.admission = new Semaphore(maxCalls);
- this.timeout = Duration.ofNanos(timeout.toNanos());
+ this.maxCalls = maxCalls;
+ this.timeout = timeout == null ? null : Duration.ofNanos(timeout.toNanos());
+ this.closeTimeout = Duration.ofNanos(closeTimeout.toNanos());
}
@FunctionalInterface
@@ -39,52 +64,78 @@ public interface Operation {
}
public Mono submit(Operation operation, Runnable cancel) {
- return Mono.create(
- sink -> {
- AtomicReference worker = new AtomicReference<>();
- AtomicBoolean cancelled = new AtomicBoolean();
- sink.onCancel(
- () -> {
- cancelled.set(true);
- cancel.run();
- Thread thread = worker.get();
- if (thread != null) {
- thread.interrupt();
- }
- });
- synchronized (this) {
- if (closed || !admission.tryAcquire()) {
- sink.error(
- new IOException(
- "MCP transport closed or call limit exceeded"));
- return;
- }
- executor.execute(
- () -> {
- Thread thread = Thread.currentThread();
- worker.set(thread);
- Exception error = null;
- try {
- if (cancelled.get()) {
- return;
- }
- operation.run();
- } catch (Exception e) {
- error = e;
- } finally {
- admission.release();
- }
- if (!cancelled.get()) {
- if (error == null) {
- sink.success();
- } else {
- sink.error(error);
- }
- }
- });
- }
- })
- .timeout(timeout);
+ Mono call = Mono.create(sink -> admit(sink, operation, cancel));
+ return timeout == null ? call : call.timeout(timeout);
+ }
+
+ /** Admits a call, or refuses it at once; cancelling it interrupts its worker. */
+ private void admit(MonoSink sink, Operation operation, Runnable cancel) {
+ AtomicReference worker = new AtomicReference<>();
+ AtomicBoolean cancelled = new AtomicBoolean();
+ sink.onCancel(
+ () -> {
+ cancelled.set(true);
+ cancel.run();
+ Thread thread = worker.get();
+ if (thread != null) {
+ thread.interrupt();
+ }
+ });
+ synchronized (this) {
+ if (closed || !admission.tryAcquire()) {
+ sink.error(new IOException("MCP transport closed or call limit exceeded"));
+ return;
+ }
+ executor.execute(() -> work(sink, operation, worker, cancelled));
+ }
+ }
+
+ /**
+ * Runs an admitted call and signals its end. The permit is released after the signal, so a
+ * caller of {@link #awaitIdle(Duration)} sees what the call's subscribers did when it ended.
+ */
+ private void work(
+ MonoSink sink,
+ Operation operation,
+ AtomicReference worker,
+ AtomicBoolean cancelled) {
+ worker.set(Thread.currentThread());
+ try {
+ if (cancelled.get()) {
+ return;
+ }
+ Exception error = null;
+ try {
+ operation.run();
+ } catch (Exception e) {
+ error = e;
+ }
+ if (cancelled.get()) {
+ return;
+ }
+ if (error == null) {
+ sink.success();
+ } else {
+ sink.error(error);
+ }
+ } finally {
+ admission.release();
+ }
+ }
+
+ /**
+ * Waits for the running calls to end, without refusing new ones.
+ *
+ * @param wait How long to wait at most.
+ * @return True if no call was running.
+ * @throws InterruptedException If interrupted while waiting.
+ */
+ public boolean awaitIdle(Duration wait) throws InterruptedException {
+ if (!admission.tryAcquire(maxCalls, wait.toNanos(), TimeUnit.NANOSECONDS)) {
+ return false;
+ }
+ admission.release(maxCalls);
+ return true;
}
public void close() throws IOException {
@@ -93,7 +144,7 @@ public void close() throws IOException {
executor.shutdownNow();
}
try {
- if (!executor.awaitTermination(timeout.toNanos(), TimeUnit.NANOSECONDS)) {
+ if (!executor.awaitTermination(closeTimeout.toNanos(), TimeUnit.NANOSECONDS)) {
throw new IOException("MCP worker shutdown timed out");
}
} catch (InterruptedException e) {
diff --git a/org.restlet.ext.mcp/src/test/java/org/restlet/ext/mcp/McpModernServerTestCase.java b/org.restlet.ext.mcp/src/test/java/org/restlet/ext/mcp/McpModernServerTestCase.java
index 673241154a..3d7019b917 100644
--- a/org.restlet.ext.mcp/src/test/java/org/restlet/ext/mcp/McpModernServerTestCase.java
+++ b/org.restlet.ext.mcp/src/test/java/org/restlet/ext/mcp/McpModernServerTestCase.java
@@ -24,7 +24,10 @@
import io.modelcontextprotocol.modern.McpSchema.TextContent;
import io.modelcontextprotocol.modern.McpSchema.Tool;
import io.modelcontextprotocol.modern.server.McpAsyncResponse;
+import io.modelcontextprotocol.modern.server.feature.McpAsyncPrompt;
+import io.modelcontextprotocol.modern.server.feature.McpAsyncResource;
import io.modelcontextprotocol.modern.server.feature.McpAsyncTool;
+import io.modelcontextprotocol.modern.server.feature.ServerChange;
import io.modelcontextprotocol.server.McpStatelessServerFeatures;
import io.modelcontextprotocol.spec.McpSchema;
import java.io.ByteArrayOutputStream;
@@ -43,6 +46,8 @@
import org.restlet.Response;
import org.restlet.data.MediaType;
import org.restlet.data.Method;
+import org.restlet.data.ServerSentEvent;
+import org.restlet.representation.EventStreamReader;
import org.restlet.representation.StringRepresentation;
import reactor.core.publisher.Mono;
@@ -442,7 +447,7 @@ public void write(byte[] b, int off, int len) throws java.io.IOException {
"echo:freed",
firstText(
result(
- call(
+ callWhenFree(
adapter,
"tools/call",
Map.of(
@@ -765,7 +770,7 @@ public void flush() throws java.io.IOException {
"echo:freed",
firstText(
result(
- call(
+ callWhenFree(
adapter,
"tools/call",
Map.of(
@@ -817,7 +822,7 @@ void aReleasedUnwrittenStreamCancelsTheTool() throws Exception {
"echo:freed",
firstText(
result(
- call(
+ callWhenFree(
adapter,
"tools/call",
Map.of(
@@ -828,6 +833,146 @@ void aReleasedUnwrittenStreamCancelsTheTool() throws Exception {
"echo"))));
}
+ /** Modern resources, templates and prompts are served next to the bridged ones. */
+ @Test
+ void modernResourcesAndPromptsAreServed() throws Exception {
+ McpServerAdapter adapter = adapter();
+ addResourcesAndPrompts(adapter);
+ addModernResourcesAndPrompts(adapter);
+ adapter.start();
+
+ List> resources =
+ (List>) result(call(adapter, "resources/list", Map.of(), null)).get("resources");
+ assertEquals(
+ List.of("test://fixed", "test://modern"),
+ resources.stream().map(each -> ((Map, ?>) each).get("uri")).toList());
+ List> templates =
+ (List>)
+ result(call(adapter, "resources/templates/list", Map.of(), null))
+ .get("resourceTemplates");
+ assertEquals(
+ List.of("test://items/{id}", "test://things/{id}"),
+ templates.stream().map(each -> ((Map, ?>) each).get("uriTemplate")).toList());
+ for (String uri : List.of("test://modern", "test://things/3")) {
+ Map read =
+ result(call(adapter, "resources/read", Map.of("uri", uri), uri));
+ Map, ?> content = (Map, ?>) ((List>) read.get("contents")).get(0);
+ assertEquals(uri, content.get("uri"));
+ assertEquals("modern", content.get("text"));
+ }
+ List> prompts =
+ (List>) result(call(adapter, "prompts/list", Map.of(), null)).get("prompts");
+ assertEquals(
+ List.of("greet", "welcome"),
+ prompts.stream().map(each -> ((Map, ?>) each).get("name")).toList());
+ Map prompt =
+ result(call(adapter, "prompts/get", Map.of("name", "welcome"), "welcome"));
+ assertEquals("modern welcome", prompt.get("description"));
+ }
+
+ /** A URI, URI template or prompt name used twice across both lists fails the start. */
+ @Test
+ void aDuplicateResourceOrPromptFailsTheStart() {
+ McpServerAdapter uri = adapter();
+ addResourcesAndPrompts(uri);
+ uri.getModernResources()
+ .add(
+ new McpModernResource(
+ io.modelcontextprotocol.modern.McpSchema.Resource.builder(
+ "test://fixed", "again")
+ .build(),
+ modernRead()));
+ McpServerAdapter template = adapter();
+ addResourcesAndPrompts(template);
+ template.getModernResourceTemplates()
+ .add(
+ new McpModernResourceTemplate(
+ io.modelcontextprotocol.modern.McpSchema.ResourceTemplate.builder(
+ "test://items/{id}", "again")
+ .build(),
+ modernRead()));
+ McpServerAdapter prompt = adapter();
+ addResourcesAndPrompts(prompt);
+ prompt.getModernPrompts().add(modernPrompt("greet"));
+
+ for (var each :
+ Map.of(uri, "test://fixed", template, "items/{id}", prompt, "greet").entrySet()) {
+ IllegalArgumentException error =
+ org.junit.jupiter.api.Assertions.assertThrows(
+ IllegalArgumentException.class, each.getKey()::start);
+ assertTrue(error.getMessage().contains(each.getValue()), error.getMessage());
+ }
+ }
+
+ /**
+ * A listen stream is acknowledged, then carries the changes the application publishes; it does
+ * not take a call slot, and ends with its result when the adapter stops.
+ */
+ @Test
+ void aListenStreamCarriesChangesUntilTheAdapterStops() throws Exception {
+ McpServerAdapter adapter = adapter();
+ adapter.setMaxConcurrentCalls(1);
+ adapter.start();
+
+ Response response = adapter.handle(listen());
+ assertEquals(200, response.getStatus().getCode());
+ assertEquals(MediaType.TEXT_EVENT_STREAM, response.getEntity().getMediaType());
+ EventStreamReader reader = open(response);
+ assertEquals(
+ io.modelcontextprotocol.modern.McpSchema
+ .METHOD_NOTIFICATION_SUBSCRIPTIONS_ACKNOWLEDGED,
+ next(reader).get("method"));
+
+ assertNotNull(result(call(adapter, "tools/list", Map.of(), null)).get("tools"));
+ adapter.getChanges().broadcast(new ServerChange.ToolsListChanged());
+ assertEquals(
+ io.modelcontextprotocol.modern.McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED,
+ next(reader).get("method"));
+
+ adapters.remove(adapter);
+ adapter.stop();
+ Map last = next(reader);
+ assertEquals(1, ((Number) last.get("id")).intValue(), last.toString());
+ assertTrue(last.containsKey("result"), last.toString());
+ // Nothing follows the result, such as an error cutting the stream short.
+ assertEquals(
+ null,
+ CompletableFuture.supplyAsync(
+ () -> {
+ try {
+ ServerSentEvent event = reader.readEvent();
+ while (event != null
+ && (event.getData() == null
+ || event.getData().isEmpty())) {
+ event = reader.readEvent();
+ }
+ return event == null ? null : event.getData();
+ } catch (java.io.IOException e) {
+ throw new IllegalStateException(e);
+ }
+ })
+ .get(10, TimeUnit.SECONDS));
+ }
+
+ /** Listen streams beyond the limit are refused, without affecting calls. */
+ @Test
+ void theListenLimitRefusesAnExtraStream() throws Exception {
+ McpServerAdapter adapter = adapter();
+ adapter.setMaxListenStreams(1);
+ adapter.start();
+
+ Response first = adapter.handle(listen());
+ EventStreamReader reader = open(first);
+ assertEquals(
+ io.modelcontextprotocol.modern.McpSchema
+ .METHOD_NOTIFICATION_SUBSCRIPTIONS_ACKNOWLEDGED,
+ next(reader).get("method"));
+ Response second = adapter.handle(listen());
+
+ assertEquals(503, second.getStatus().getCode(), body(second));
+ assertNotNull(result(call(adapter, "tools/list", Map.of(), null)).get("tools"));
+ }
+
private McpServerAdapter adapter() {
McpServerAdapter adapter = new McpServerAdapter();
McpServerAdapterTestCase.addEcho(adapter);
@@ -906,6 +1051,107 @@ private static void addResourcesAndPrompts(McpServerAdapter adapter) {
null))));
}
+ private static void addModernResourcesAndPrompts(McpServerAdapter adapter) {
+ adapter.getModernResources()
+ .add(
+ new McpModernResource(
+ io.modelcontextprotocol.modern.McpSchema.Resource.builder(
+ "test://modern", "modern")
+ .build(),
+ modernRead()));
+ adapter.getModernResourceTemplates()
+ .add(
+ new McpModernResourceTemplate(
+ io.modelcontextprotocol.modern.McpSchema.ResourceTemplate.builder(
+ "test://things/{id}", "things")
+ .build(),
+ modernRead()));
+ adapter.getModernPrompts().add(modernPrompt("welcome"));
+ }
+
+ private static McpAsyncResource modernRead() {
+ return McpAsyncResource.of(
+ (ctx, request) -> {
+ io.modelcontextprotocol.modern.McpSchema.ReadResourceOutcome outcome =
+ io.modelcontextprotocol.modern.McpSchema.ReadResourceResult.builder(
+ List.of(
+ new io.modelcontextprotocol.modern.McpSchema
+ .TextResourceContents(
+ request.uri(),
+ "text/plain",
+ "modern",
+ null)))
+ .build();
+ return Mono.just(McpAsyncResponse.result(outcome));
+ });
+ }
+
+ private static McpAsyncPrompt modernPrompt(String name) {
+ return McpAsyncPrompt.of(
+ io.modelcontextprotocol.modern.McpSchema.Prompt.builder(name).build(),
+ (ctx, request) -> {
+ io.modelcontextprotocol.modern.McpSchema.GetPromptOutcome outcome =
+ new io.modelcontextprotocol.modern.McpSchema.GetPromptResult(
+ "modern " + name,
+ List.of(
+ new io.modelcontextprotocol.modern.McpSchema
+ .PromptMessage(
+ io.modelcontextprotocol.modern.McpSchema.Role
+ .USER,
+ TextContent.builder("hi").build())),
+ null,
+ null);
+ return Mono.just(McpAsyncResponse.result(outcome));
+ });
+ }
+
+ /** A {@code subscriptions/listen} request for tools list changes. */
+ private static Request listen() {
+ return modern(
+ io.modelcontextprotocol.modern.McpSchema.METHOD_SUBSCRIPTIONS_LISTEN,
+ Map.of("notifications", Map.of("toolsListChanged", true)),
+ null,
+ meta());
+ }
+
+ /** Writes a streamed response in the background, to be read event by event. */
+ private static EventStreamReader open(Response response) throws Exception {
+ var input = new java.io.PipedInputStream(1 << 16);
+ var output = new java.io.PipedOutputStream(input);
+ Thread.ofVirtual()
+ .start(
+ () -> {
+ try (output) {
+ response.getEntity().write(output);
+ } catch (java.io.IOException _) {
+ // The reader went away.
+ }
+ });
+ return new EventStreamReader(input);
+ }
+
+ /** The next message of a stream, skipping heartbeats. */
+ private static Map next(EventStreamReader reader) throws Exception {
+ return CompletableFuture.supplyAsync(
+ () -> {
+ try {
+ for (ServerSentEvent event = reader.readEvent();
+ event != null;
+ event = reader.readEvent()) {
+ if (event.getData() != null && !event.getData().isEmpty()) {
+ return MAPPER.readValue(
+ event.getData(),
+ new TypeRef>() {});
+ }
+ }
+ throw new IllegalStateException("The stream ended");
+ } catch (java.io.IOException e) {
+ throw new IllegalStateException(e);
+ }
+ })
+ .get(10, TimeUnit.SECONDS);
+ }
+
private static McpSchema.ReadResourceResult contents(String uri) {
return new McpSchema.ReadResourceResult(
List.of(new McpSchema.TextResourceContents(uri, "text/plain", "text", null)), null);
@@ -927,6 +1173,22 @@ private static Response call(
return adapter.handle(modern(method, params, name, meta()));
}
+ /**
+ * Calls once the call slot an ended exchange held is free again: its worker frees it just after
+ * the handler sees the cancellation.
+ */
+ private static Response callWhenFree(
+ McpServerAdapter adapter, String method, Map params, String name) {
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10);
+ Response response = call(adapter, method, params, name);
+ while (response.getStatus().getCode() == 503 && System.nanoTime() < deadline) {
+ response.getEntity().release();
+ java.util.concurrent.locks.LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(10));
+ response = call(adapter, method, params, name);
+ }
+ return response;
+ }
+
/** A 2026-07-28 request with its _meta and headers. */
private static Request modern(
String method, Map params, String name, Map meta) {
diff --git a/org.restlet.ext.mcp/src/test/java/org/restlet/ext/mcp/McpModernStdioTestCase.java b/org.restlet.ext.mcp/src/test/java/org/restlet/ext/mcp/McpModernStdioTestCase.java
new file mode 100644
index 0000000000..0d804b924e
--- /dev/null
+++ b/org.restlet.ext.mcp/src/test/java/org/restlet/ext/mcp/McpModernStdioTestCase.java
@@ -0,0 +1,345 @@
+/**
+ * Copyright 2005-2026 Qlik
+ *
+ * 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
+ *
+ * Restlet is a registered trademark of QlikTech International AB.
+ */
+package org.restlet.ext.mcp;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import io.modelcontextprotocol.json.McpJsonDefaults;
+import io.modelcontextprotocol.json.McpJsonMapper;
+import io.modelcontextprotocol.json.TypeRef;
+import io.modelcontextprotocol.modern.McpSchema;
+import io.modelcontextprotocol.modern.McpSchema.CallToolOutcome;
+import io.modelcontextprotocol.modern.McpSchema.CallToolResult;
+import io.modelcontextprotocol.modern.McpSchema.MetaKeys;
+import io.modelcontextprotocol.modern.McpSchema.TextContent;
+import io.modelcontextprotocol.modern.McpSchema.Tool;
+import io.modelcontextprotocol.modern.server.McpAsyncResponse;
+import io.modelcontextprotocol.modern.server.feature.McpAsyncTool;
+import io.modelcontextprotocol.modern.server.feature.ServerChange;
+import java.io.BufferedReader;
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.nio.channels.Channels;
+import java.nio.channels.Pipe;
+import java.nio.charset.StandardCharsets;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.restlet.Context;
+import org.restlet.Server;
+import org.restlet.data.Protocol;
+import org.restlet.engine.Engine;
+import reactor.core.publisher.Mono;
+
+/**
+ * MCP {@code 2026-07-28} over stdio, one JSON-RPC message per line: the revision is read from each
+ * message's {@code _meta}, streamed messages come as separate lines, listen streams stay open, and
+ * a cancelled request ends without a response.
+ */
+class McpModernStdioTestCase {
+ private static final McpJsonMapper MAPPER = McpJsonDefaults.getMapper();
+
+ /** The adapter's listen limit, above the default, which stdio must not lower. */
+ private static final int LISTENS = McpProtocol.DEFAULT_MAX_LISTEN_STREAMS + 2;
+
+ private final CountDownLatch cancelled = new CountDownLatch(1);
+ private final StdioServerHelper prototype = new StdioServerHelper(null);
+ private McpServerAdapter adapter;
+ private Server server;
+ private OutputStream toServer;
+ private final java.util.concurrent.BlockingQueue lines =
+ new java.util.concurrent.LinkedBlockingQueue<>();
+
+ @BeforeEach
+ void start() throws Exception {
+ Pipe requests = Pipe.open();
+ Pipe responses = Pipe.open();
+ org.restlet.util.StreamTransport stream =
+ new org.restlet.util.StreamTransport(
+ Channels.newInputStream(requests.source()),
+ Channels.newOutputStream(responses.sink()),
+ () -> {
+ try {
+ requests.source().close();
+ responses.sink().close();
+ } catch (IOException e) {
+ throw new java.io.UncheckedIOException(e);
+ }
+ });
+ toServer = Channels.newOutputStream(requests.sink());
+ BufferedReader fromServer =
+ new BufferedReader(
+ new InputStreamReader(
+ Channels.newInputStream(responses.source()),
+ StandardCharsets.UTF_8));
+ Thread.ofVirtual()
+ .start(
+ () -> {
+ try {
+ for (String line = fromServer.readLine();
+ line != null;
+ line = fromServer.readLine()) {
+ lines.add(line);
+ }
+ } catch (IOException _) {
+ // The server output ended.
+ }
+ });
+ adapter = new McpServerAdapter();
+ McpServerAdapterTestCase.addEcho(adapter);
+ adapter.getModernTools()
+ .add(
+ McpAsyncTool.of(
+ Tool.builder("progress", Map.of("type", "object")).build(),
+ (ctx, request) ->
+ Mono.just(
+ McpAsyncResponse.streaming(
+ notifier ->
+ notifier.progress(1, 2.0, "half")
+ .then(
+ Mono.just(
+ text(
+ "done")))))));
+ adapter.getModernTools()
+ .add(
+ McpAsyncTool.of(
+ Tool.builder("endless", Map.of("type", "object")).build(),
+ (ctx, request) ->
+ Mono.just(
+ McpAsyncResponse.streaming(
+ notifier ->
+ notifier.progress(
+ 1, null, "started")
+ .then(
+ Mono
+ .
+ never()
+ .doOnCancel(
+ cancelled
+ ::countDown))))));
+ adapter.setMaxListenStreams(LISTENS);
+ adapter.start();
+ Engine.getInstance().getRegisteredServers().add(prototype);
+ Context context = new Context();
+ context.getAttributes().put(McpProtocol.STREAM_TRANSPORT_ATTRIBUTE, stream);
+ server =
+ new Server(
+ context,
+ List.of(Protocol.STDIO),
+ null,
+ -1,
+ adapter,
+ StdioServerHelper.class.getCanonicalName());
+ server.start();
+ }
+
+ @AfterEach
+ void stop() throws Exception {
+ try {
+ if (server != null) {
+ server.stop();
+ }
+ } finally {
+ Engine.getInstance().getRegisteredServers().remove(prototype);
+ adapter.stop();
+ }
+ }
+
+ /** Discovery and calls are answered on one line each, without HTTP headers. */
+ @Test
+ void discoveryAndCallsAreAnswered() throws Exception {
+ send(request(1, "server/discover", Map.of(), meta()));
+ Map discover = result(read());
+ assertEquals(List.of(McpProtocol.MODERN_VERSION), discover.get("supportedVersions"));
+
+ send(
+ request(
+ 2,
+ "tools/call",
+ Map.of("name", "echo", "arguments", Map.of("text", "line")),
+ meta()));
+ Map reply = read();
+ assertEquals(2, ((Number) reply.get("id")).intValue());
+ assertEquals("echo:line", firstText(result(reply)));
+ }
+
+ /** A streamed call writes its progress, then its result, as separate lines. */
+ @Test
+ void streamedMessagesComeAsSeparateLines() throws Exception {
+ Map meta = meta();
+ meta.put(MetaKeys.PROGRESS_TOKEN, "p1");
+ send(request(3, "tools/call", Map.of("name", "progress", "arguments", Map.of()), meta));
+
+ Map progress = read();
+ assertEquals("notifications/progress", progress.get("method"));
+ Map reply = read();
+ assertEquals(3, ((Number) reply.get("id")).intValue());
+ assertEquals("done", firstText(result(reply)));
+ }
+
+ /** A cancelled request ends its handler and gets no response; the cancel needs no _meta. */
+ @Test
+ void aCancelledRequestEndsWithoutAResponse() throws Exception {
+ Map meta = meta();
+ meta.put(MetaKeys.PROGRESS_TOKEN, "p2");
+ send(request(4, "tools/call", Map.of("name", "endless", "arguments", Map.of()), meta));
+ assertEquals("notifications/progress", read().get("method"));
+
+ Map cancel = new LinkedHashMap<>();
+ cancel.put("jsonrpc", "2.0");
+ cancel.put("method", "notifications/cancelled");
+ cancel.put("params", Map.of("requestId", 4));
+ send(cancel);
+ assertTrue(cancelled.await(10, TimeUnit.SECONDS), "the handler was not cancelled");
+
+ send(
+ request(
+ 5,
+ "tools/call",
+ Map.of("name", "echo", "arguments", Map.of("text", "after")),
+ meta()));
+ Map next = read();
+ assertEquals(5, ((Number) next.get("id")).intValue(), next.toString());
+ }
+
+ /** A listen stream stays open over stdio, carrying changes between other responses. */
+ @Test
+ void aListenStreamCarriesChanges() throws Exception {
+ send(
+ request(
+ 6,
+ McpSchema.METHOD_SUBSCRIPTIONS_LISTEN,
+ Map.of("notifications", Map.of("toolsListChanged", true)),
+ meta()));
+ assertEquals(
+ McpSchema.METHOD_NOTIFICATION_SUBSCRIPTIONS_ACKNOWLEDGED, read().get("method"));
+
+ send(
+ request(
+ 7,
+ "tools/call",
+ Map.of("name", "echo", "arguments", Map.of("text", "beside")),
+ meta()));
+ assertEquals("echo:beside", firstText(result(read())));
+ adapter.getChanges().broadcast(new ServerChange.ToolsListChanged());
+ assertEquals(McpSchema.METHOD_NOTIFICATION_TOOLS_LIST_CHANGED, read().get("method"));
+ }
+
+ /** A request naming the cancellation method is answered, and cancels nothing. */
+ @Test
+ void aRequestNamedCancelledIsAnsweredAndCancelsNothing() throws Exception {
+ Map meta = meta();
+ meta.put(MetaKeys.PROGRESS_TOKEN, "p3");
+ send(request(8, "tools/call", Map.of("name", "endless", "arguments", Map.of()), meta));
+ assertEquals("notifications/progress", read().get("method"));
+
+ send(request(9, "notifications/cancelled", Map.of("requestId", 8), meta()));
+ Map reply = read();
+
+ assertEquals(9, ((Number) reply.get("id")).intValue(), reply.toString());
+ assertTrue(reply.containsKey("error"), reply.toString());
+ assertEquals(1, cancelled.getCount(), "the call was cancelled");
+ }
+
+ /** The adapter's listen limit applies over stdio, above the default too. */
+ @Test
+ void theAdapterListenLimitAppliesOverStdio() throws Exception {
+ for (int id = 100; id < 100 + LISTENS; id++) {
+ send(
+ request(
+ id,
+ McpSchema.METHOD_SUBSCRIPTIONS_LISTEN,
+ Map.of("notifications", Map.of("toolsListChanged", true)),
+ meta()));
+ }
+ for (int i = 0; i < LISTENS; i++) {
+ Map ack = read();
+ assertEquals(
+ McpSchema.METHOD_NOTIFICATION_SUBSCRIPTIONS_ACKNOWLEDGED,
+ ack.get("method"),
+ ack.toString());
+ }
+
+ send(
+ request(
+ 200,
+ McpSchema.METHOD_SUBSCRIPTIONS_LISTEN,
+ Map.of("notifications", Map.of("toolsListChanged", true)),
+ meta()));
+ Map refused = read();
+ assertEquals(200, ((Number) refused.get("id")).intValue(), refused.toString());
+ assertTrue(refused.containsKey("error"), refused.toString());
+ }
+
+ /** A request whose id is neither a string nor a number is answered with a null id. */
+ @Test
+ void anInvalidIdIsNotEchoed() throws Exception {
+ Map envelope = request(0, "tools/list", Map.of(), meta());
+ envelope.put("id", Map.of("not", "an id"));
+ send(envelope);
+ Map reply = read();
+
+ assertTrue(reply.containsKey("id"), reply.toString());
+ assertEquals(null, reply.get("id"), reply.toString());
+ assertTrue(reply.containsKey("error"), reply.toString());
+ }
+
+ private static Map request(
+ int id, String method, Map params, Map meta) {
+ Map withMeta = new LinkedHashMap<>(params);
+ withMeta.put("_meta", meta);
+ Map envelope = new LinkedHashMap<>();
+ envelope.put("jsonrpc", "2.0");
+ envelope.put("id", id);
+ envelope.put("method", method);
+ envelope.put("params", withMeta);
+ return envelope;
+ }
+
+ private static Map meta() {
+ Map meta = new LinkedHashMap<>();
+ meta.put(MetaKeys.PROTOCOL_VERSION, McpProtocol.MODERN_VERSION);
+ meta.put(MetaKeys.CLIENT_CAPABILITIES, Map.of());
+ return meta;
+ }
+
+ private void send(Map message) throws IOException {
+ toServer.write(
+ (MAPPER.writeValueAsString(message) + "\n").getBytes(StandardCharsets.UTF_8));
+ toServer.flush();
+ }
+
+ private Map read() throws Exception {
+ String line = lines.poll(10, TimeUnit.SECONDS);
+ assertNotNull(line, "no server output");
+ return MAPPER.readValue(line, new TypeRef>() {});
+ }
+
+ @SuppressWarnings("unchecked")
+ private static Map result(Map envelope) {
+ assertTrue(envelope.containsKey("result"), envelope.toString());
+ return (Map) envelope.get("result");
+ }
+
+ private static String firstText(Map result) {
+ return (String) ((Map, ?>) ((List>) result.get("content")).get(0)).get("text");
+ }
+
+ private static CallToolResult text(String text) {
+ return CallToolResult.builder().addContent(TextContent.builder(text).build()).build();
+ }
+}