From 3681108d26b5c94126af89ee117a00431fd4dabe Mon Sep 17 00:00:00 2001 From: tvallin Date: Tue, 11 Nov 2025 11:16:26 +0100 Subject: [PATCH 1/5] Mcp Root feature --- .../extensions/mcp/codegen/McpCodegen.java | 22 ++++ .../extensions/mcp/codegen/McpTypes.java | 1 + .../extensions/mcp/server/McpDecorators.java | 13 +++ .../extensions/mcp/server/McpFeatures.java | 32 +++++- .../extensions/mcp/server/McpJsonRpc.java | 41 +++++++ .../mcp/server/McpRootBlueprint.java | 44 ++++++++ .../mcp/server/McpRootException.java | 40 +++++++ .../extensions/mcp/server/McpRoots.java | 100 +++++++++++++++++ .../extensions/mcp/server/McpSampling.java | 11 +- .../mcp/server/McpServerConfigBlueprint.java | 9 ++ .../mcp/server/McpServerFeature.java | 17 +++ .../extensions/mcp/server/McpSession.java | 6 +- .../mcp/server/ConfigurationTest.java | 5 + .../extensions/mcp/server/McpRootTest.java | 59 ++++++++++ .../test/resources/application-server.yaml | 2 + .../extensions/mcp/codegen/McpTypesTest.java | 2 + .../mcp/tests/declarative/McpRootsServer.java | 102 +++++++++++++++++ .../declarative/McpSdkRootsServerTest.java | 90 +++++++++++++++ .../extensions/mcp/tests/RootsServer.java | 103 ++++++++++++++++++ .../mcp/tests/AbstractMcpSdkRootsTest.java | 103 ++++++++++++++++++ .../mcp/tests/McpSdkSseRootTest.java | 44 ++++++++ .../mcp/tests/McpSdkStreamableRootTest.java | 44 ++++++++ 22 files changed, 880 insertions(+), 10 deletions(-) create mode 100644 server/src/main/java/io/helidon/extensions/mcp/server/McpRootBlueprint.java create mode 100644 server/src/main/java/io/helidon/extensions/mcp/server/McpRootException.java create mode 100644 server/src/main/java/io/helidon/extensions/mcp/server/McpRoots.java create mode 100644 server/src/test/java/io/helidon/extensions/mcp/server/McpRootTest.java create mode 100644 tests/declarative/src/main/java/io/helidon/extensions/mcp/tests/declarative/McpRootsServer.java create mode 100644 tests/declarative/src/test/java/io/helidon/extensions/mcp/tests/declarative/McpSdkRootsServerTest.java create mode 100644 tests/mcp/src/main/java/io/helidon/extensions/mcp/tests/RootsServer.java create mode 100644 tests/mcp/src/test/java/io/helidon/extensions/mcp/tests/AbstractMcpSdkRootsTest.java create mode 100644 tests/mcp/src/test/java/io/helidon/extensions/mcp/tests/McpSdkSseRootTest.java create mode 100644 tests/mcp/src/test/java/io/helidon/extensions/mcp/tests/McpSdkStreamableRootTest.java diff --git a/codegen/src/main/java/io/helidon/extensions/mcp/codegen/McpCodegen.java b/codegen/src/main/java/io/helidon/extensions/mcp/codegen/McpCodegen.java index 1d161cdd..51b01ef3 100644 --- a/codegen/src/main/java/io/helidon/extensions/mcp/codegen/McpCodegen.java +++ b/codegen/src/main/java/io/helidon/extensions/mcp/codegen/McpCodegen.java @@ -88,6 +88,7 @@ import static io.helidon.extensions.mcp.codegen.McpTypes.MCP_RESOURCE_UNSUBSCRIBER_INTERFACE; import static io.helidon.extensions.mcp.codegen.McpTypes.MCP_ROLE; import static io.helidon.extensions.mcp.codegen.McpTypes.MCP_ROLE_ENUM; +import static io.helidon.extensions.mcp.codegen.McpTypes.MCP_ROOTS; import static io.helidon.extensions.mcp.codegen.McpTypes.MCP_SAMPLING; import static io.helidon.extensions.mcp.codegen.McpTypes.MCP_SERVER; import static io.helidon.extensions.mcp.codegen.McpTypes.MCP_SERVER_CONFIG; @@ -454,6 +455,10 @@ private void addResourceMethod(Method.Builder builder, String uri, ClassModel.Bu parameters.add("request.features().sampling()"); continue; } + if (MCP_ROOTS.equals(parameter.typeName())) { + parameters.add("request.features().roots()"); + continue; + } if (isResourceTemplate(uri)) { if (MCP_PARAMETERS.equals(parameter.typeName())) { parameters.add("request.parameters()"); @@ -606,6 +611,16 @@ private void addPromptMethod(Method.Builder builder, ClassModel.Builder classMod builder.addContentLine("var sampling = features.sampling();"); continue; } + if (MCP_ROOTS.equals(param.typeName())) { + if (!featuresLocalVar) { + addFeaturesLocalVar(builder, classModel); + featuresLocalVar = true; + } + parameters.add("roots"); + classModel.addImport(MCP_ROOTS); + builder.addContentLine("var roots = features.roots();"); + continue; + } if (!parametersLocalVar) { addParametersLocalVar(builder, classModel); parametersLocalVar = true; @@ -799,6 +814,12 @@ private void addToolMethod(Method.Builder builder, ClassModel.Builder classModel builder.addContentLine("var sampling = request.features().sampling();"); continue; } + if (MCP_ROOTS.equals(param.typeName())) { + parameters.add("roots"); + classModel.addImport(MCP_ROOTS); + builder.addContentLine("var roots = request.features().roots();"); + continue; + } if (TypeNames.STRING.equals(param.typeName())) { parameters.add(param.elementName()); builder.addContent("var ") @@ -1065,6 +1086,7 @@ private TypeName generatedTypeName(TypeName factoryTypeName, String suffix) { private boolean isIgnoredSchemaElement(TypeName typeName) { return MCP_REQUEST.equals(typeName) + || MCP_ROOTS.equals(typeName) || MCP_LOGGER.equals(typeName) || MCP_FEATURES.equals(typeName) || MCP_PROGRESS.equals(typeName) diff --git a/codegen/src/main/java/io/helidon/extensions/mcp/codegen/McpTypes.java b/codegen/src/main/java/io/helidon/extensions/mcp/codegen/McpTypes.java index b862b0f3..c9dac6bb 100644 --- a/codegen/src/main/java/io/helidon/extensions/mcp/codegen/McpTypes.java +++ b/codegen/src/main/java/io/helidon/extensions/mcp/codegen/McpTypes.java @@ -43,6 +43,7 @@ private McpTypes() { static final TypeName MCP_RESOURCE_TEMPLATES_PAGE_SIZE = TypeName.create("io.helidon.extensions.mcp.server.Mcp.ResourceTemplatesPageSize"); //Implementations + static final TypeName MCP_ROOTS = TypeName.create("io.helidon.extensions.mcp.server.McpRoots"); static final TypeName MCP_LOGGER = TypeName.create("io.helidon.extensions.mcp.server.McpLogger"); static final TypeName MCP_ROLE_ENUM = TypeName.create("io.helidon.extensions.mcp.server.McpRole"); static final TypeName MCP_REQUEST = TypeName.create("io.helidon.extensions.mcp.server.McpRequest"); diff --git a/server/src/main/java/io/helidon/extensions/mcp/server/McpDecorators.java b/server/src/main/java/io/helidon/extensions/mcp/server/McpDecorators.java index f4b57a98..17d8b049 100644 --- a/server/src/main/java/io/helidon/extensions/mcp/server/McpDecorators.java +++ b/server/src/main/java/io/helidon/extensions/mcp/server/McpDecorators.java @@ -15,6 +15,7 @@ */ package io.helidon.extensions.mcp.server; +import java.net.URI; import java.util.Optional; import io.helidon.builder.api.Prototype; @@ -81,6 +82,18 @@ public void decorate(McpSamplingRequest.BuilderBase builder, Optional, URI> { + @Override + public void decorate(McpRoot.BuilderBase builder, URI uri) { + if (!uri.getScheme().equals("file")) { + throw new McpRootException("Root URI scheme must be file"); + } + } + } + static boolean isPositiveAndLessThanOne(Double value) { return 0 <= value && value <= 1.0; } diff --git a/server/src/main/java/io/helidon/extensions/mcp/server/McpFeatures.java b/server/src/main/java/io/helidon/extensions/mcp/server/McpFeatures.java index 635a4936..69f3feb4 100644 --- a/server/src/main/java/io/helidon/extensions/mcp/server/McpFeatures.java +++ b/server/src/main/java/io/helidon/extensions/mcp/server/McpFeatures.java @@ -47,39 +47,48 @@ * {@link io.helidon.extensions.mcp.server.McpSampling} - MCP Sampling feature. * Send sampling messages to client. * + *
  • + * {@link io.helidon.extensions.mcp.server.McpRoots} - MCP Roots feature. + * List the available filesystem root from client. + *
  • * */ public final class McpFeatures { private final LazyValue cancellation = LazyValue.create(McpCancellation::new); private final JsonRpcResponse response; + private final McpServerConfig config; private final McpSession session; + private McpRoots roots; private SseSink sseSink; private McpLogger logger; private McpSampling sampling; private McpProgress progress; private McpSubscriptions subscriptions; - McpFeatures(McpSession session) { + McpFeatures(McpServerConfig config, McpSession session) { Objects.requireNonNull(session, "session is null"); this.session = session; this.response = null; + this.config = config; } - McpFeatures(McpSession session, JsonRpcResponse response) { + McpFeatures(McpServerConfig config, McpSession session, JsonRpcResponse response) { Objects.requireNonNull(response, "response is null"); Objects.requireNonNull(session, "session is null"); this.response = response; this.session = session; + this.config = config; } - McpFeatures(McpSession session, JsonRpcResponse response, SseSink sseSink) { + McpFeatures(McpServerConfig config, McpSession session, JsonRpcResponse response, SseSink sseSink) { Objects.requireNonNull(response, "response is null"); Objects.requireNonNull(session, "session is null"); Objects.requireNonNull(sseSink, "sseSink is null"); this.response = response; this.session = session; this.sseSink = sseSink; + this.config = config; } /** @@ -116,6 +125,23 @@ public McpLogger logger() { return logger; } + /** + * Get a {@link io.helidon.extensions.mcp.server.McpRoots} feature. + * + * @return the MCP roots + */ + public McpRoots roots() { + if (roots == null) { + if (response != null) { + sseSink = getOrCreateSseSink(); + roots = new McpRoots(config, session, sseSink); + } else { + roots = new McpRoots(config, session); + } + } + return roots; + } + /** * Get a {@link io.helidon.extensions.mcp.server.McpSampling} feature. * diff --git a/server/src/main/java/io/helidon/extensions/mcp/server/McpJsonRpc.java b/server/src/main/java/io/helidon/extensions/mcp/server/McpJsonRpc.java index a0201136..006b379b 100644 --- a/server/src/main/java/io/helidon/extensions/mcp/server/McpJsonRpc.java +++ b/server/src/main/java/io/helidon/extensions/mcp/server/McpJsonRpc.java @@ -18,18 +18,21 @@ import java.io.ByteArrayOutputStream; import java.io.StringReader; +import java.net.URI; import java.nio.charset.StandardCharsets; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.IntStream; import io.helidon.common.media.type.MediaType; import io.helidon.common.media.type.MediaTypes; import io.helidon.jsonrpc.core.JsonRpcError; import jakarta.json.Json; +import jakarta.json.JsonArray; import jakarta.json.JsonArrayBuilder; import jakarta.json.JsonBuilderFactory; import jakarta.json.JsonObject; @@ -622,6 +625,14 @@ static JsonObject createJsonRpcRequest(long id, String method, JsonObjectBuilder .build(); } + static JsonObject createJsonRpcRequest(long id, String method) { + return JSON_BUILDER_FACTORY.createObjectBuilder() + .add("jsonrpc", "2.0") + .add("id", id) + .add("method", method) + .build(); + } + static JsonObject createJsonRpcErrorResponse(long id, JsonObjectBuilder params) { return JSON_BUILDER_FACTORY.createObjectBuilder() .add("jsonrpc", "2.0") @@ -645,6 +656,29 @@ static JsonObject timeoutResponse(long requestId) { return createJsonRpcErrorResponse(requestId, error); } + static List parseRoots(JsonObject response) { + find(response, "error") + .filter(McpJsonRpc::isJsonObject) + .map(JsonValue::asJsonObject) + .map(JsonRpcError::create) + .ifPresent(error -> { + throw new McpRootException(error.message()); + }); + JsonArray roots = find(response, "result") + .map(JsonValue::asJsonObject) + .flatMap(result -> find(result, "roots")) + .map(JsonValue::asJsonArray) + .orElseThrow(() -> new McpRootException("Wrong response format: %s".formatted(response))); + + return IntStream.range(0, roots.size()) + .mapToObj(roots::getJsonObject) + .map(root -> McpRoot.builder() + .uri(URI.create(root.getString("uri"))) + .name(findString(root, "name")) + .build()) + .toList(); + } + private static McpSamplingMessage parseMessage(McpRole role, JsonObject object) { String type = object.getString("type").toUpperCase(); McpSamplingMessageType messageType = McpSamplingMessageType.valueOf(type); @@ -670,6 +704,13 @@ private static Optional find(JsonObject object, String key) { return Optional.empty(); } + private static Optional findString(JsonObject object, String key) { + if (object.containsKey(key)) { + return Optional.of(object.getString(key)); + } + return Optional.empty(); + } + private static boolean isJsonObject(JsonValue value) { return JsonValue.ValueType.OBJECT.equals(value.getValueType()); } diff --git a/server/src/main/java/io/helidon/extensions/mcp/server/McpRootBlueprint.java b/server/src/main/java/io/helidon/extensions/mcp/server/McpRootBlueprint.java new file mode 100644 index 00000000..58826bd0 --- /dev/null +++ b/server/src/main/java/io/helidon/extensions/mcp/server/McpRootBlueprint.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.extensions.mcp.server; + +import java.net.URI; +import java.util.Optional; + +import io.helidon.builder.api.Option; +import io.helidon.builder.api.Prototype; + +/** + * Roots define the boundaries of where servers can operate within the filesystem. + */ +@Prototype.Blueprint +interface McpRootBlueprint { + /** + * Unique identifier for the root. This MUST be a {@code file://} URI + * in the current specification. + * + * @return root uri + */ + @Option.Decorator(McpDecorators.RootUriDecorator.class) + URI uri(); + + /** + * The root name. + * + * @return root name + */ + Optional name(); +} diff --git a/server/src/main/java/io/helidon/extensions/mcp/server/McpRootException.java b/server/src/main/java/io/helidon/extensions/mcp/server/McpRootException.java new file mode 100644 index 00000000..5bb11059 --- /dev/null +++ b/server/src/main/java/io/helidon/extensions/mcp/server/McpRootException.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.extensions.mcp.server; + +/** + * MCP root exception thrown during a root list request to the client. + */ +public class McpRootException extends RuntimeException { + /** + * Creates a new MCP sampling exception with specified details message. + * + * @param message message exception + */ + public McpRootException(String message) { + super(message); + } + + /** + * Creates a new MCP sampling exception with specified details message and its cause. + * + * @param message message exception + * @param cause cause exception + */ + public McpRootException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/server/src/main/java/io/helidon/extensions/mcp/server/McpRoots.java b/server/src/main/java/io/helidon/extensions/mcp/server/McpRoots.java new file mode 100644 index 00000000..8e07a841 --- /dev/null +++ b/server/src/main/java/io/helidon/extensions/mcp/server/McpRoots.java @@ -0,0 +1,100 @@ +/* + * Copyright (c) 2025 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.extensions.mcp.server; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; + +import io.helidon.http.sse.SseEvent; +import io.helidon.webserver.sse.SseSink; + +import jakarta.json.JsonObject; + +import static io.helidon.extensions.mcp.server.McpJsonRpc.METHOD_ROOTS_LIST; +import static io.helidon.extensions.mcp.server.McpJsonRpc.createJsonRpcRequest; +import static io.helidon.extensions.mcp.server.McpJsonRpc.parseRoots; + +/** + * MCP roots feature. + */ +public final class McpRoots extends McpFeature { + private final List roots = new CopyOnWriteArrayList<>(); + private final Duration timeout; + private final boolean enabled; + + McpRoots(McpServerConfig config, McpSession session) { + super(session); + this.timeout = config.rootListTimeout(); + this.enabled = session().capabilities().contains(McpCapability.ROOTS); + } + + McpRoots(McpServerConfig config, McpSession session, SseSink sseSink) { + super(session, sseSink); + this.timeout = config.rootListTimeout(); + this.enabled = session().capabilities().contains(McpCapability.ROOTS); + } + + /** + * Whether the connected client supports roots feature. + * + * @return {@code true} if the connected client supports roots feature, + * {@code false} otherwise. + */ + public boolean enabled() { + return enabled; + } + + /** + * Get the current list of root available from client. + * + * @return list of root + * @throws io.helidon.extensions.mcp.server.McpRootException if an error occurs + */ + public List listRoots() throws McpRootException { + if (!enabled) { + throw new McpRootException("Roots feature is not supported by the client"); + } + boolean updateRoot = session().context() + .get(McpRootClassifier.class, Boolean.class) + .orElse(false); + return updateRoot ? sendListRoot(timeout) : roots; + } + + /** + * Sends a {@code roots/list} request and update the list of root. + * + * @return list of root + */ + private List sendListRoot(Duration timeout) { + long id = session().jsonRpcId(); + JsonObject request = createJsonRpcRequest(id, METHOD_ROOTS_LIST); + sseSink().ifPresentOrElse(sink -> sink.emit(SseEvent.builder() + .name("message") + .data(request) + .build()), + () -> session().send(request)); + JsonObject response = session().pollResponse(id, timeout); + List updatedRoots = parseRoots(response); + roots.clear(); + roots.addAll(updatedRoots); + session().context().register(McpRootClassifier.class, false); + return roots; + } + + static class McpRootClassifier { + } +} diff --git a/server/src/main/java/io/helidon/extensions/mcp/server/McpSampling.java b/server/src/main/java/io/helidon/extensions/mcp/server/McpSampling.java index 8c249a52..392b3f77 100644 --- a/server/src/main/java/io/helidon/extensions/mcp/server/McpSampling.java +++ b/server/src/main/java/io/helidon/extensions/mcp/server/McpSampling.java @@ -32,13 +32,16 @@ */ public final class McpSampling extends McpFeature { private static final System.Logger LOGGER = System.getLogger(McpSampling.class.getName()); + private final boolean enabled; McpSampling(McpSession session) { super(session); + this.enabled = session().capabilities().contains(McpCapability.SAMPLING); } McpSampling(McpSession session, SseSink sseSink) { super(session, sseSink); + this.enabled = session().capabilities().contains(McpCapability.SAMPLING); } /** @@ -48,9 +51,7 @@ public final class McpSampling extends McpFeature { * {@code false} otherwise. */ public boolean enabled() { - return session() - .capabilities() - .contains(McpCapability.SAMPLING); + return enabled; } /** @@ -74,6 +75,9 @@ public McpSamplingResponse request(Consumer request) * @throws io.helidon.extensions.mcp.server.McpSamplingException when an error occurs */ public McpSamplingResponse request(McpSamplingRequest request) throws McpSamplingException { + if (!enabled) { + throw new McpSamplingException("Sampling feature is not supported by client"); + } long id = session().jsonRpcId(); JsonObject payload = createSamplingRequest(id, request); @@ -91,5 +95,4 @@ public McpSamplingResponse request(McpSamplingRequest request) throws McpSamplin } return createSamplingResponse(response); } - } diff --git a/server/src/main/java/io/helidon/extensions/mcp/server/McpServerConfigBlueprint.java b/server/src/main/java/io/helidon/extensions/mcp/server/McpServerConfigBlueprint.java index 5c737590..1676de32 100644 --- a/server/src/main/java/io/helidon/extensions/mcp/server/McpServerConfigBlueprint.java +++ b/server/src/main/java/io/helidon/extensions/mcp/server/McpServerConfigBlueprint.java @@ -165,4 +165,13 @@ interface McpServerConfigBlueprint extends Prototype.Factory { */ @Option.Singular List resourceUnsubscribers(); + + /** + * Roots list request timeout. Default is five seconds. + * + * @return root list timeout + */ + @Option.Configured + @Option.Default("PT5S") + Duration rootListTimeout(); } diff --git a/server/src/main/java/io/helidon/extensions/mcp/server/McpServerFeature.java b/server/src/main/java/io/helidon/extensions/mcp/server/McpServerFeature.java index b88a37e5..b72040e1 100644 --- a/server/src/main/java/io/helidon/extensions/mcp/server/McpServerFeature.java +++ b/server/src/main/java/io/helidon/extensions/mcp/server/McpServerFeature.java @@ -62,6 +62,7 @@ import static io.helidon.extensions.mcp.server.McpJsonRpc.METHOD_LOGGING_SET_LEVEL; import static io.helidon.extensions.mcp.server.McpJsonRpc.METHOD_NOTIFICATION_CANCELED; import static io.helidon.extensions.mcp.server.McpJsonRpc.METHOD_NOTIFICATION_INITIALIZED; +import static io.helidon.extensions.mcp.server.McpJsonRpc.METHOD_NOTIFICATION_ROOTS_LIST_CHANGED; import static io.helidon.extensions.mcp.server.McpJsonRpc.METHOD_PING; import static io.helidon.extensions.mcp.server.McpJsonRpc.METHOD_PROMPT_GET; import static io.helidon.extensions.mcp.server.McpJsonRpc.METHOD_PROMPT_LIST; @@ -175,6 +176,7 @@ private McpServerFeature(McpServerConfig config) { builder.method(METHOD_SESSION_DISCONNECT, this::disconnect); builder.method(METHOD_NOTIFICATION_CANCELED, this::notificationCancelRpc); builder.method(METHOD_NOTIFICATION_INITIALIZED, this::notificationInitRpc); + builder.method(METHOD_NOTIFICATION_ROOTS_LIST_CHANGED, this::notificationRootsListRpc); builder.errorHandler(this::handleErrorRequest); builder.exception(McpInternalException.class, this::mcpInternalExceptionHandler); @@ -296,11 +298,13 @@ private void sse(ServerRequest request, ServerResponse response) { response.status(Status.NOT_FOUND_404).send(); return; } + session.get().context().register(McpRoots.McpRootClassifier.class, true); // streamable HTTP and active session response.status(Status.METHOD_NOT_ALLOWED_405).send(); } else { String sessionId = UUID.randomUUID().toString(); McpSession session = new McpSession(sessions, capabilities, config); + session.context().register(McpRoots.McpRootClassifier.class, true); sessions.put(sessionId, session); try (SseSink sink = response.sink(SseSink.TYPE)) { @@ -398,6 +402,19 @@ private void notificationCancelRpc(JsonRpcRequest req, JsonRpcResponse res) { session.features(requestId.get()).ifPresent(feature -> feature.cancellation().cancel(cancelReason, requestId.get())); } + private void notificationRootsListRpc(JsonRpcRequest req, JsonRpcResponse res) { + Optional session = findSession(req); + if (session.isEmpty()) { + if (LOGGER.isLoggable(Level.TRACE)) { + LOGGER.log(Level.TRACE, "No session found for roots update: %s".formatted(req.asJsonObject())); + } + return; + } + session.get() + .context() + .register(McpRoots.McpRootClassifier.class, true); + } + private void initializeRpc(JsonRpcRequest req, JsonRpcResponse res) { Optional foundSession = findSession(req); diff --git a/server/src/main/java/io/helidon/extensions/mcp/server/McpSession.java b/server/src/main/java/io/helidon/extensions/mcp/server/McpSession.java index ced6b0dd..cd7dcc1a 100644 --- a/server/src/main/java/io/helidon/extensions/mcp/server/McpSession.java +++ b/server/src/main/java/io/helidon/extensions/mcp/server/McpSession.java @@ -150,19 +150,19 @@ void disconnect() { } McpFeatures createFeatures(JsonValue requestId) { - McpFeatures feat = new McpFeatures(this); + McpFeatures feat = new McpFeatures(config, this); features.put(requestId, feat); return feat; } McpFeatures createFeatures(JsonRpcResponse res, JsonValue requestId) { - McpFeatures feat = new McpFeatures(this, res); + McpFeatures feat = new McpFeatures(config, this, res); features.put(requestId, feat); return feat; } McpFeatures createFeatures(JsonRpcResponse res, JsonValue requestId, SseSink sseSink) { - McpFeatures feat = new McpFeatures(this, res, sseSink); + McpFeatures feat = new McpFeatures(config, this, res, sseSink); features.put(requestId, feat); return feat; } diff --git a/server/src/test/java/io/helidon/extensions/mcp/server/ConfigurationTest.java b/server/src/test/java/io/helidon/extensions/mcp/server/ConfigurationTest.java index ef20681e..c12bb782 100644 --- a/server/src/test/java/io/helidon/extensions/mcp/server/ConfigurationTest.java +++ b/server/src/test/java/io/helidon/extensions/mcp/server/ConfigurationTest.java @@ -16,6 +16,7 @@ package io.helidon.extensions.mcp.server; +import java.time.Duration; import java.util.Map; import io.helidon.config.Config; @@ -44,6 +45,8 @@ void testConfiguration() { assertThat(config.promptsPageSize(), is(10)); assertThat(config.resourcesPageSize(), is(10)); assertThat(config.resourceTemplatesPageSize(), is(10)); + assertThat(config.rootListTimeout(), is(Duration.ofSeconds(1))); + assertThat(config.subscriptionTimeout(), is(Duration.ofSeconds(1))); } @Test @@ -58,6 +61,8 @@ void testConfigurationDefaultValues() { assertThat(config.promptsPageSize(), is(DEFAULT_PAGE_SIZE)); assertThat(config.resourcesPageSize(), is(DEFAULT_PAGE_SIZE)); assertThat(config.resourceTemplatesPageSize(), is(DEFAULT_PAGE_SIZE)); + assertThat(config.rootListTimeout(), is(Duration.ofSeconds(5))); + assertThat(config.subscriptionTimeout(), is(Duration.ofMinutes(2))); } @ParameterizedTest diff --git a/server/src/test/java/io/helidon/extensions/mcp/server/McpRootTest.java b/server/src/test/java/io/helidon/extensions/mcp/server/McpRootTest.java new file mode 100644 index 00000000..928e2e01 --- /dev/null +++ b/server/src/test/java/io/helidon/extensions/mcp/server/McpRootTest.java @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.extensions.mcp.server; + +import java.net.URI; + +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.is; + +class McpRootTest { + @Test + void testMcpRoot() { + McpRoot root = McpRoot.builder() + .uri(URI.create("file://resource.txt")) + .build(); + + assertThat(root.name().isEmpty(), is(true)); + assertThat(root.uri(), is(URI.create("file://resource.txt"))); + } + + @Test + void testMcpRootName() { + McpRoot root = McpRoot.builder() + .name("foo") + .uri(URI.create("file://resource.txt")) + .build(); + + assertThat(root.name().isEmpty(), is(false)); + assertThat(root.name().get(), is("foo")); + assertThat(root.uri(), is(URI.create("file://resource.txt"))); + } + + @Test + void testMcpRootWithInvalidUri() { + try { + McpRoot.builder() + .uri(URI.create("https://foo.com")) + .build(); + assertThat("Wrong scheme URI must throw an exception", true, is(false)); + } catch (McpRootException ex) { + assertThat(ex.getMessage(), is("Root URI scheme must be file")); + } + } +} diff --git a/server/src/test/resources/application-server.yaml b/server/src/test/resources/application-server.yaml index 66978a0e..aa8b0b16 100644 --- a/server/src/test/resources/application-server.yaml +++ b/server/src/test/resources/application-server.yaml @@ -23,3 +23,5 @@ mcp: prompts-page-size: 10 resources-page-size: 10 resource-templates-page-size: 10 + subscription-timeout: "PT1S" + root-list-timeout: "PT1S" diff --git a/tests/codegen/src/test/java/io/helidon/extensions/mcp/codegen/McpTypesTest.java b/tests/codegen/src/test/java/io/helidon/extensions/mcp/codegen/McpTypesTest.java index 7a4b50e8..12efa1f0 100644 --- a/tests/codegen/src/test/java/io/helidon/extensions/mcp/codegen/McpTypesTest.java +++ b/tests/codegen/src/test/java/io/helidon/extensions/mcp/codegen/McpTypesTest.java @@ -47,6 +47,7 @@ import io.helidon.extensions.mcp.server.McpResourceSubscriber; import io.helidon.extensions.mcp.server.McpResourceUnsubscriber; import io.helidon.extensions.mcp.server.McpRole; +import io.helidon.extensions.mcp.server.McpRoots; import io.helidon.extensions.mcp.server.McpSampling; import io.helidon.extensions.mcp.server.McpServerConfig; import io.helidon.extensions.mcp.server.McpTool; @@ -110,6 +111,7 @@ void testTypes() { checkField(toCheck, checked, fields, "MCP_RESOURCE_TEMPLATES_PAGE_SIZE", Mcp.ResourceTemplatesPageSize.class); checkField(toCheck, checked, fields, "MCP_COMPLETION", Mcp.Completion.class); checkField(toCheck, checked, fields, "MCP_DESCRIPTION", Mcp.Description.class); + checkField(toCheck, checked, fields, "MCP_ROOTS", McpRoots.class); checkField(toCheck, checked, fields, "MCP_LOGGER", McpLogger.class); checkField(toCheck, checked, fields, "MCP_ROLE_ENUM", McpRole.class); checkField(toCheck, checked, fields, "MCP_REQUEST", McpRequest.class); diff --git a/tests/declarative/src/main/java/io/helidon/extensions/mcp/tests/declarative/McpRootsServer.java b/tests/declarative/src/main/java/io/helidon/extensions/mcp/tests/declarative/McpRootsServer.java new file mode 100644 index 00000000..5fbe9cb3 --- /dev/null +++ b/tests/declarative/src/main/java/io/helidon/extensions/mcp/tests/declarative/McpRootsServer.java @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.extensions.mcp.tests.declarative; + +import java.util.List; + +import io.helidon.common.media.type.MediaTypes; +import io.helidon.extensions.mcp.server.Mcp; +import io.helidon.extensions.mcp.server.McpPromptContent; +import io.helidon.extensions.mcp.server.McpPromptContents; +import io.helidon.extensions.mcp.server.McpRequest; +import io.helidon.extensions.mcp.server.McpResourceContent; +import io.helidon.extensions.mcp.server.McpResourceContents; +import io.helidon.extensions.mcp.server.McpRole; +import io.helidon.extensions.mcp.server.McpRoots; +import io.helidon.extensions.mcp.server.McpToolContent; +import io.helidon.extensions.mcp.server.McpToolContents; + +@Mcp.Server +@Mcp.Path("/roots") +class McpRootsServer { + @Mcp.Tool("Sampling tool") + List tool(McpRoots sampling) { + return List.of(McpToolContents.textContent("")); + } + + @Mcp.Tool("Sampling tool") + List tool1(McpRoots sampling, String value) { + return List.of(McpToolContents.textContent("")); + } + + @Mcp.Tool("Sampling tool") + String tool2(McpRoots sampling) { + return ""; + } + + @Mcp.Tool("Sampling tool") + String tool3(McpRoots sampling, String value) { + return ""; + } + + @Mcp.Prompt("Sampling prompt") + List prompt(McpRoots sampling) { + return List.of(McpPromptContents.textContent("", McpRole.USER)); + } + + @Mcp.Prompt("Sampling prompt") + List prompt1(McpRoots sampling, String value) { + return List.of(McpPromptContents.textContent("", McpRole.USER)); + } + + @Mcp.Prompt("Sampling prompt") + String prompt2(McpRoots sampling) { + return ""; + } + + @Mcp.Prompt("Sampling prompt") + String prompt3(McpRoots sampling, String value) { + return ""; + } + + @Mcp.Resource(uri = "https://resource", + description = "Sampling resource", + mediaType = MediaTypes.TEXT_PLAIN_VALUE) + List resource(McpRoots sampling) { + return List.of(McpResourceContents.textContent("")); + } + + @Mcp.Resource(uri = "https://resource1", + description = "Sampling resource", + mediaType = MediaTypes.TEXT_PLAIN_VALUE) + List resource1(McpRoots sampling, McpRequest request) { + return List.of(McpResourceContents.textContent("")); + } + + @Mcp.Resource(uri = "https://resource2", + description = "Sampling resource", + mediaType = MediaTypes.TEXT_PLAIN_VALUE) + String resource2(McpRoots sampling) { + return ""; + } + + @Mcp.Resource(uri = "https://resource3", + description = "Sampling resource", + mediaType = MediaTypes.TEXT_PLAIN_VALUE) + String resource3(McpRoots sampling, McpRequest request) { + return ""; + } +} diff --git a/tests/declarative/src/test/java/io/helidon/extensions/mcp/tests/declarative/McpSdkRootsServerTest.java b/tests/declarative/src/test/java/io/helidon/extensions/mcp/tests/declarative/McpSdkRootsServerTest.java new file mode 100644 index 00000000..cf284d6f --- /dev/null +++ b/tests/declarative/src/test/java/io/helidon/extensions/mcp/tests/declarative/McpSdkRootsServerTest.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2024, 2025 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.extensions.mcp.tests.declarative; + +import java.util.List; +import java.util.Map; + +import io.helidon.webserver.WebServer; +import io.helidon.webserver.testing.junit5.ServerTest; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.HttpClientSseClientTransport; +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.instanceOf; +import static org.hamcrest.Matchers.is; + +@ServerTest +class McpSdkRootsServerTest { + private final McpSyncClient client; + + McpSdkRootsServerTest(WebServer server) { + client = McpClient.sync(HttpClientSseClientTransport.builder("http://localhost:" + server.port()) + .sseEndpoint("/roots") + .build()) + .build(); + client.initialize(); + } + + @ParameterizedTest + @ValueSource(strings = {"tool", "tool1", "tool2", "tool3"}) + void testRootsTool(String name) { + McpSchema.CallToolResult result = client.callTool(McpSchema.CallToolRequest.builder() + .name(name) + .build()); + assertThat(result.isError(), is(false)); + + var contents = result.content(); + assertThat(contents.size(), is(1)); + assertThat(contents.getFirst(), instanceOf(McpSchema.TextContent.class)); + + McpSchema.TextContent text = (McpSchema.TextContent) contents.getFirst(); + assertThat(text.text(), is("")); + } + + @ParameterizedTest + @ValueSource(strings = {"prompt", "prompt1", "prompt2", "prompt3"}) + void testRootsPrompt(String name) { + McpSchema.GetPromptResult result = client.getPrompt(new McpSchema.GetPromptRequest(name, Map.of())); + List messages = result.messages(); + assertThat(messages.size(), is(1)); + + var content = messages.getFirst().content(); + assertThat(content, instanceOf(McpSchema.TextContent.class)); + + McpSchema.TextContent text = (McpSchema.TextContent) content; + assertThat(text.text(), is("")); + } + + @ParameterizedTest + @ValueSource(strings = {"resource", "resource1", "resource2", "resource3"}) + void testRootsResource(String name) { + McpSchema.ReadResourceResult result = client.readResource(new McpSchema.ReadResourceRequest("https://" + name)); + List contents = result.contents(); + assertThat(contents.size(), is(1)); + + McpSchema.ResourceContents content = contents.getFirst(); + assertThat(content, instanceOf(McpSchema.TextResourceContents.class)); + + McpSchema.TextResourceContents text = (McpSchema.TextResourceContents) content; + assertThat(text.text(), is("")); + } +} diff --git a/tests/mcp/src/main/java/io/helidon/extensions/mcp/tests/RootsServer.java b/tests/mcp/src/main/java/io/helidon/extensions/mcp/tests/RootsServer.java new file mode 100644 index 00000000..cc8f67b8 --- /dev/null +++ b/tests/mcp/src/main/java/io/helidon/extensions/mcp/tests/RootsServer.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.extensions.mcp.tests; + +import java.net.URI; +import java.util.List; +import java.util.Optional; +import java.util.function.Function; + +import io.helidon.extensions.mcp.server.McpRequest; +import io.helidon.extensions.mcp.server.McpRoot; +import io.helidon.extensions.mcp.server.McpServerFeature; +import io.helidon.extensions.mcp.server.McpTool; +import io.helidon.extensions.mcp.server.McpToolContent; +import io.helidon.extensions.mcp.server.McpToolContents; +import io.helidon.extensions.mcp.server.McpToolErrorException; +import io.helidon.webserver.http.HttpRouting; + +class RootsServer { + private RootsServer() { + } + + static void setUpRoute(HttpRouting.Builder builder) { + builder.addFeature(McpServerFeature.builder() + .path("/") + .addTool(new RootNameTool()) + .addTool(new RootUriTool())); + } + + private static class RootNameTool implements McpTool { + @Override + public String name() { + return "roots-name-tool"; + } + + @Override + public String description() { + return "Returns roots names"; + } + + @Override + public String schema() { + return ""; + } + + @Override + public Function> tool() { + return request -> { + List roots = request.features().roots().listRoots(); + return roots.stream() + .map(McpRoot::name) + .flatMap(Optional::stream) + .map(McpToolContents::textContent) + .toList(); + }; + } + } + + private static class RootUriTool implements McpTool { + @Override + public String name() { + return "roots-uri-tool"; + } + + @Override + public String description() { + return "Returns roots URIs"; + } + + @Override + public String schema() { + return ""; + } + + @Override + public Function> tool() { + return request -> { + if (!request.features().roots().enabled()) { + throw new McpToolErrorException("Roots is disabled"); + } + List roots = request.features().roots().listRoots(); + return roots.stream() + .map(McpRoot::uri) + .map(URI::toASCIIString) + .map(McpToolContents::textContent) + .toList(); + }; + } + } +} diff --git a/tests/mcp/src/test/java/io/helidon/extensions/mcp/tests/AbstractMcpSdkRootsTest.java b/tests/mcp/src/test/java/io/helidon/extensions/mcp/tests/AbstractMcpSdkRootsTest.java new file mode 100644 index 00000000..c202ede3 --- /dev/null +++ b/tests/mcp/src/test/java/io/helidon/extensions/mcp/tests/AbstractMcpSdkRootsTest.java @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2025 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.extensions.mcp.tests; + +import java.util.List; + +import io.helidon.webserver.http.HttpRouting; +import io.helidon.webserver.testing.junit5.SetUpRoute; + +import io.modelcontextprotocol.spec.McpSchema; +import org.junit.jupiter.api.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsInAnyOrder; +import static org.hamcrest.Matchers.is; + +abstract class AbstractMcpSdkRootsTest extends AbstractMcpSdkTest { + @SetUpRoute + static void routing(HttpRouting.Builder builder) { + RootsServer.setUpRoute(builder); + } + + static List roots() { + return List.of(new McpSchema.Root("file://foo.txt", "foo"), + new McpSchema.Root("file://bar.txt", "bar")); + } + + @Test + void testRootNameTool() { + McpSchema.CallToolResult result = client().callTool(McpSchema.CallToolRequest.builder() + .name("roots-name-tool") + .build()); + assertThat(result.isError(), is(false)); + + List contents = result.content(); + assertThat(contents.size(), is(2)); + + List names = contents.stream() + .filter(content -> content instanceof McpSchema.TextContent) + .map(McpSchema.TextContent.class::cast) + .map(McpSchema.TextContent::text) + .toList(); + assertThat(names.size(), is(2)); + assertThat(names, containsInAnyOrder("foo", "bar")); + } + + @Test + void testRootUriTool() { + McpSchema.CallToolResult result = client().callTool(McpSchema.CallToolRequest.builder() + .name("roots-uri-tool") + .build()); + assertThat(result.isError(), is(false)); + + List contents = result.content(); + assertThat(contents.size(), is(2)); + + List names = contents.stream() + .filter(content -> content instanceof McpSchema.TextContent) + .map(McpSchema.TextContent.class::cast) + .map(McpSchema.TextContent::text) + .toList(); + assertThat(names.size(), is(2)); + assertThat(names, containsInAnyOrder("file://foo.txt", "file://bar.txt")); + } + + @Test + void testRootUpdate() { + McpSchema.CallToolResult result = client().callTool(McpSchema.CallToolRequest.builder() + .name("roots-name-tool") + .build()); + assertThat(result.isError(), is(false)); + + client().addRoot(new McpSchema.Root("file://file.txt", "file")); + client().rootsListChangedNotification(); + + result = client().callTool(McpSchema.CallToolRequest.builder() + .name("roots-name-tool") + .build()); + List contents = result.content(); + assertThat(contents.size(), is(3)); + + List names = contents.stream() + .filter(content -> content instanceof McpSchema.TextContent) + .map(McpSchema.TextContent.class::cast) + .map(McpSchema.TextContent::text) + .toList(); + assertThat(names.size(), is(3)); + assertThat(names, containsInAnyOrder("foo", "bar", "file")); + } +} diff --git a/tests/mcp/src/test/java/io/helidon/extensions/mcp/tests/McpSdkSseRootTest.java b/tests/mcp/src/test/java/io/helidon/extensions/mcp/tests/McpSdkSseRootTest.java new file mode 100644 index 00000000..a67fea7f --- /dev/null +++ b/tests/mcp/src/test/java/io/helidon/extensions/mcp/tests/McpSdkSseRootTest.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.extensions.mcp.tests; + +import io.helidon.webserver.WebServer; +import io.helidon.webserver.testing.junit5.ServerTest; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.spec.McpSchema; + +@ServerTest +class McpSdkSseRootTest extends AbstractMcpSdkRootsTest { + private final McpSyncClient client; + + McpSdkSseRootTest(WebServer server) { + client = McpClient.sync(sse(server.port())) + .capabilities(new McpSchema.ClientCapabilities(null, + new McpSchema.ClientCapabilities.RootCapabilities(true), + null, + null)) + .roots(roots()) + .build(); + client.initialize(); + } + + @Override + McpSyncClient client() { + return client; + } +} diff --git a/tests/mcp/src/test/java/io/helidon/extensions/mcp/tests/McpSdkStreamableRootTest.java b/tests/mcp/src/test/java/io/helidon/extensions/mcp/tests/McpSdkStreamableRootTest.java new file mode 100644 index 00000000..6290ba4f --- /dev/null +++ b/tests/mcp/src/test/java/io/helidon/extensions/mcp/tests/McpSdkStreamableRootTest.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 Oracle and/or its affiliates. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.helidon.extensions.mcp.tests; + +import io.helidon.webserver.WebServer; +import io.helidon.webserver.testing.junit5.ServerTest; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.spec.McpSchema; + +@ServerTest +class McpSdkStreamableRootTest extends AbstractMcpSdkRootsTest { + private final McpSyncClient client; + + McpSdkStreamableRootTest(WebServer server) { + client = McpClient.sync(streamable(server.port())) + .capabilities(new McpSchema.ClientCapabilities(null, + new McpSchema.ClientCapabilities.RootCapabilities(true), + null, + null)) + .roots(roots()) + .build(); + client.initialize(); + } + + @Override + McpSyncClient client() { + return client; + } +} From 5882b3d2a35919b8d25711a88a7c262fa095a08e Mon Sep 17 00:00:00 2001 From: tvallin Date: Tue, 11 Nov 2025 15:49:52 +0100 Subject: [PATCH 2/5] fixes typos --- .../io/helidon/extensions/mcp/server/McpRootException.java | 6 +++--- .../extensions/mcp/server/McpServerConfigBlueprint.java | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/server/src/main/java/io/helidon/extensions/mcp/server/McpRootException.java b/server/src/main/java/io/helidon/extensions/mcp/server/McpRootException.java index 5bb11059..7db43f3a 100644 --- a/server/src/main/java/io/helidon/extensions/mcp/server/McpRootException.java +++ b/server/src/main/java/io/helidon/extensions/mcp/server/McpRootException.java @@ -16,11 +16,11 @@ package io.helidon.extensions.mcp.server; /** - * MCP root exception thrown during a root list request to the client. + * MCP root exception thrown when processing root feature. */ public class McpRootException extends RuntimeException { /** - * Creates a new MCP sampling exception with specified details message. + * Creates a new MCP root exception with specified details message. * * @param message message exception */ @@ -29,7 +29,7 @@ public McpRootException(String message) { } /** - * Creates a new MCP sampling exception with specified details message and its cause. + * Creates a new MCP root exception with specified details message and its cause. * * @param message message exception * @param cause cause exception diff --git a/server/src/main/java/io/helidon/extensions/mcp/server/McpServerConfigBlueprint.java b/server/src/main/java/io/helidon/extensions/mcp/server/McpServerConfigBlueprint.java index 1676de32..b893fad3 100644 --- a/server/src/main/java/io/helidon/extensions/mcp/server/McpServerConfigBlueprint.java +++ b/server/src/main/java/io/helidon/extensions/mcp/server/McpServerConfigBlueprint.java @@ -169,7 +169,7 @@ interface McpServerConfigBlueprint extends Prototype.Factory { /** * Roots list request timeout. Default is five seconds. * - * @return root list timeout + * @return roots list timeout */ @Option.Configured @Option.Default("PT5S") From 6ab1fa29ccbdaee757124de0cc29a0ccd3d2a469 Mon Sep 17 00:00:00 2001 From: tvallin Date: Tue, 11 Nov 2025 20:37:19 +0100 Subject: [PATCH 3/5] add documentation --- docs/mcp-declarative/README.md | 36 ++++++++++++++++ docs/mcp/README.md | 75 +++++++++++++++++++++++++++------- 2 files changed, 97 insertions(+), 14 deletions(-) diff --git a/docs/mcp-declarative/README.md b/docs/mcp-declarative/README.md index 28a90642..78b0d7b3 100644 --- a/docs/mcp-declarative/README.md +++ b/docs/mcp-declarative/README.md @@ -505,6 +505,42 @@ Below is an example of a tool that uses the Sampling feature. `McpSampling` obje ```java @Mcp.Tool("Uses MCP Sampling to ask the connected client model.") List samplingTool(McpSampling sampling) { + if (!sampling.enabled()) { + throw new McpToolErrorException("This tool requires sampling feature"); + } + + try { + var message = McpSamplingMessages.textContent("Write a 3-line summary of Helidon MCP Sampling.", McpRole.USER); + McpSamplingResponse response = sampling.request(req -> req + .timeout(Duration.ofSeconds(10)) + .systemPrompt("You are a concise, helpful assistant.") + .addMessage(message)); + return List.of(McpToolContents.textContent(response.asTextMessage())); + } catch (McpSamplingException e) { + throw new McpToolErrorException(e.getMessage()); + } +} +``` + +### Roots + +See the full [roots documentation details](../mcp/README.md#roots) + +#### Example + +Below is an example of a tool that uses the Roots feature. `McpRoots` object can be used as method parameter. + +```java +@Mcp.Tool("Request MCP Roots to the connected client.") +List rootsTool(McpRoots roots) { + if (!roots.enabled()) { + throw new McpToolErrorException(McpToolContents.textContent("Roots are not supported by the client")); + } + roots = request.features().roots().listRoots(); + McpRoot root = roots.getFirst(); + URI uri = root.uri(); + String name = root.name().orElse("Unknown"); + return List.of(McpToolContents.textContent("Server updated roots")); } ``` diff --git a/docs/mcp/README.md b/docs/mcp/README.md index 822fe457..fcbb957d 100644 --- a/docs/mcp/README.md +++ b/docs/mcp/README.md @@ -736,22 +736,69 @@ class SamplingTool implements McpTool { } @Override - public List process(McpRequest request) { - var sampling = request.features().sampling(); + public Function> tool() { + return request -> { + var sampling = request.features().sampling(); - if (!sampling.enabled()) { - throw new McpToolErrorException("This tool requires sampling feature"); - } + if (!sampling.enabled()) { + throw new McpToolErrorException("This tool requires sampling feature"); + } - try { - McpSamplingResponse response = sampling.request(req -> req - .timeout(Duration.ofSeconds(10)) - .systemPrompt("You are a concise, helpful assistant.") - .addMessage(McpSamplingMessages.textContent("Write a 3-line summary of Helidon MCP Sampling.", McpRole.USER))); - return List.of(McpToolContents.textContent(response.asTextMessage())); - } catch (McpSamplingException e) { - throw new McpToolErrorException(e.getMessage()); - } + try { + var message = McpSamplingMessages.textContent("Write a 3-line summary of Helidon MCP Sampling.", McpRole.USER); + McpSamplingResponse response = sampling.request(req -> req + .timeout(Duration.ofSeconds(10)) + .systemPrompt("You are a concise, helpful assistant.") + .addMessage(message)); + return List.of(McpToolContents.textContent(response.asTextMessage())); + } catch (McpSamplingException e) { + throw new McpToolErrorException(e.getMessage()); + } + }; + } +} +``` + +### Roots + +Roots establish the boundaries within the filesystem that define where servers are permitted to operate. They determine which +directories and files a server can access. Servers can request the current list of roots from compatible clients and receive +notifications whenever that list is updated. + +#### Example + +```java +class RootNameTool implements McpTool { + private List roots; + + @Override + public String name() { + return "roots-name-tool"; + } + + @Override + public String description() { + return "Get the list of roots available"; + } + + @Override + public String schema() { + return ""; + } + + @Override + public Function> tool() { + return request -> { + McpRoots roots = request.features().roots(); + if (!roots.enabled()) { + throw new McpToolErrorException(McpToolContents.textContent("Roots are not supported by the client")); + } + roots = request.features().roots().listRoots(); + McpRoot root = roots.getFirst(); + URI uri = root.uri(); + String name = root.name().orElse("Unknown"); + return List.of(McpToolContents.textContent("Server updated roots")); + }; } } ``` From e1605c8cfe39c10fb3f174f146b0d5f770a0b2cd Mon Sep 17 00:00:00 2001 From: tvallin Date: Tue, 11 Nov 2025 20:48:13 +0100 Subject: [PATCH 4/5] fix variable name --- docs/mcp/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/mcp/README.md b/docs/mcp/README.md index fcbb957d..0af19dc6 100644 --- a/docs/mcp/README.md +++ b/docs/mcp/README.md @@ -789,11 +789,11 @@ class RootNameTool implements McpTool { @Override public Function> tool() { return request -> { - McpRoots roots = request.features().roots(); - if (!roots.enabled()) { + McpRoots mcpRoots = request.features().roots(); + if (!mcpRoots.enabled()) { throw new McpToolErrorException(McpToolContents.textContent("Roots are not supported by the client")); } - roots = request.features().roots().listRoots(); + roots = mcpRoots.listRoots(); McpRoot root = roots.getFirst(); URI uri = root.uri(); String name = root.name().orElse("Unknown"); From 1c3b09129390b69a7b1b51747ae7bfc056e0b328 Mon Sep 17 00:00:00 2001 From: tvallin Date: Fri, 14 Nov 2025 09:57:16 +0100 Subject: [PATCH 5/5] review changes --- docs/mcp-declarative/README.md | 8 ++-- docs/mcp/README.md | 6 +-- .../extensions/mcp/server/McpJsonRpc.java | 9 +--- .../mcp/tests/declarative/McpRootsServer.java | 48 +++++++++---------- 4 files changed, 31 insertions(+), 40 deletions(-) diff --git a/docs/mcp-declarative/README.md b/docs/mcp-declarative/README.md index 78b0d7b3..e77fc818 100644 --- a/docs/mcp-declarative/README.md +++ b/docs/mcp-declarative/README.md @@ -532,11 +532,11 @@ Below is an example of a tool that uses the Roots feature. `McpRoots` object can ```java @Mcp.Tool("Request MCP Roots to the connected client.") -List rootsTool(McpRoots roots) { - if (!roots.enabled()) { - throw new McpToolErrorException(McpToolContents.textContent("Roots are not supported by the client")); +List rootsTool(McpRoots mcpRoots) { + if (!mcpRoots.enabled()) { + throw new McpToolErrorException("Roots are not supported by the client"); } - roots = request.features().roots().listRoots(); + List roots = mcpRoots.listRoots(); McpRoot root = roots.getFirst(); URI uri = root.uri(); String name = root.name().orElse("Unknown"); diff --git a/docs/mcp/README.md b/docs/mcp/README.md index 0af19dc6..251bfffe 100644 --- a/docs/mcp/README.md +++ b/docs/mcp/README.md @@ -769,8 +769,6 @@ notifications whenever that list is updated. ```java class RootNameTool implements McpTool { - private List roots; - @Override public String name() { return "roots-name-tool"; @@ -791,9 +789,9 @@ class RootNameTool implements McpTool { return request -> { McpRoots mcpRoots = request.features().roots(); if (!mcpRoots.enabled()) { - throw new McpToolErrorException(McpToolContents.textContent("Roots are not supported by the client")); + throw new McpToolErrorException("Roots are not supported by the client"); } - roots = mcpRoots.listRoots(); + List roots = mcpRoots.listRoots(); McpRoot root = roots.getFirst(); URI uri = root.uri(); String name = root.name().orElse("Unknown"); diff --git a/server/src/main/java/io/helidon/extensions/mcp/server/McpJsonRpc.java b/server/src/main/java/io/helidon/extensions/mcp/server/McpJsonRpc.java index 006b379b..3791dd32 100644 --- a/server/src/main/java/io/helidon/extensions/mcp/server/McpJsonRpc.java +++ b/server/src/main/java/io/helidon/extensions/mcp/server/McpJsonRpc.java @@ -674,7 +674,7 @@ static List parseRoots(JsonObject response) { .mapToObj(roots::getJsonObject) .map(root -> McpRoot.builder() .uri(URI.create(root.getString("uri"))) - .name(findString(root, "name")) + .name(Optional.ofNullable(root.getString("name", null))) .build()) .toList(); } @@ -704,13 +704,6 @@ private static Optional find(JsonObject object, String key) { return Optional.empty(); } - private static Optional findString(JsonObject object, String key) { - if (object.containsKey(key)) { - return Optional.of(object.getString(key)); - } - return Optional.empty(); - } - private static boolean isJsonObject(JsonValue value) { return JsonValue.ValueType.OBJECT.equals(value.getValueType()); } diff --git a/tests/declarative/src/main/java/io/helidon/extensions/mcp/tests/declarative/McpRootsServer.java b/tests/declarative/src/main/java/io/helidon/extensions/mcp/tests/declarative/McpRootsServer.java index 5fbe9cb3..2ba6da73 100644 --- a/tests/declarative/src/main/java/io/helidon/extensions/mcp/tests/declarative/McpRootsServer.java +++ b/tests/declarative/src/main/java/io/helidon/extensions/mcp/tests/declarative/McpRootsServer.java @@ -32,71 +32,71 @@ @Mcp.Server @Mcp.Path("/roots") class McpRootsServer { - @Mcp.Tool("Sampling tool") - List tool(McpRoots sampling) { + @Mcp.Tool("Roots tool") + List tool(McpRoots roots) { return List.of(McpToolContents.textContent("")); } - @Mcp.Tool("Sampling tool") - List tool1(McpRoots sampling, String value) { + @Mcp.Tool("Roots tool") + List tool1(McpRoots roots, String value) { return List.of(McpToolContents.textContent("")); } - @Mcp.Tool("Sampling tool") - String tool2(McpRoots sampling) { + @Mcp.Tool("Roots tool") + String tool2(McpRoots roots) { return ""; } - @Mcp.Tool("Sampling tool") - String tool3(McpRoots sampling, String value) { + @Mcp.Tool("Roots tool") + String tool3(McpRoots roots, String value) { return ""; } - @Mcp.Prompt("Sampling prompt") - List prompt(McpRoots sampling) { + @Mcp.Prompt("Roots prompt") + List prompt(McpRoots roots) { return List.of(McpPromptContents.textContent("", McpRole.USER)); } - @Mcp.Prompt("Sampling prompt") - List prompt1(McpRoots sampling, String value) { + @Mcp.Prompt("Roots prompt") + List prompt1(McpRoots roots, String value) { return List.of(McpPromptContents.textContent("", McpRole.USER)); } - @Mcp.Prompt("Sampling prompt") - String prompt2(McpRoots sampling) { + @Mcp.Prompt("Roots prompt") + String prompt2(McpRoots roots) { return ""; } - @Mcp.Prompt("Sampling prompt") - String prompt3(McpRoots sampling, String value) { + @Mcp.Prompt("Roots prompt") + String prompt3(McpRoots roots, String value) { return ""; } @Mcp.Resource(uri = "https://resource", - description = "Sampling resource", + description = "Roots resource", mediaType = MediaTypes.TEXT_PLAIN_VALUE) - List resource(McpRoots sampling) { + List resource(McpRoots roots) { return List.of(McpResourceContents.textContent("")); } @Mcp.Resource(uri = "https://resource1", - description = "Sampling resource", + description = "Roots resource", mediaType = MediaTypes.TEXT_PLAIN_VALUE) - List resource1(McpRoots sampling, McpRequest request) { + List resource1(McpRoots roots, McpRequest request) { return List.of(McpResourceContents.textContent("")); } @Mcp.Resource(uri = "https://resource2", - description = "Sampling resource", + description = "Roots resource", mediaType = MediaTypes.TEXT_PLAIN_VALUE) - String resource2(McpRoots sampling) { + String resource2(McpRoots roots) { return ""; } @Mcp.Resource(uri = "https://resource3", - description = "Sampling resource", + description = "Roots resource", mediaType = MediaTypes.TEXT_PLAIN_VALUE) - String resource3(McpRoots sampling, McpRequest request) { + String resource3(McpRoots roots, McpRequest request) { return ""; } }