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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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()");
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 ")
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
36 changes: 36 additions & 0 deletions docs/mcp-declarative/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<McpToolContent> 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<McpToolContent> rootsTool(McpRoots mcpRoots) {
if (!mcpRoots.enabled()) {
throw new McpToolErrorException("Roots are not supported by the client");
}
List<McpRoot> roots = mcpRoots.listRoots();
McpRoot root = roots.getFirst();
URI uri = root.uri();
String name = root.name().orElse("Unknown");
return List.of(McpToolContents.textContent("Server updated roots"));
}
```

Expand Down
73 changes: 59 additions & 14 deletions docs/mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -736,22 +736,67 @@ class SamplingTool implements McpTool {
}

@Override
public List<McpToolContent> process(McpRequest request) {
var sampling = request.features().sampling();
public Function<McpRequest, List<McpToolContent>> 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 {
@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<McpRequest, List<McpToolContent>> tool() {
return request -> {
McpRoots mcpRoots = request.features().roots();
if (!mcpRoots.enabled()) {
throw new McpToolErrorException("Roots are not supported by the client");
}
List<McpRoot> roots = mcpRoots.listRoots();
McpRoot root = roots.getFirst();
URI uri = root.uri();
String name = root.name().orElse("Unknown");
return List.of(McpToolContents.textContent("Server updated roots"));
};
}
}
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
*/
package io.helidon.extensions.mcp.server;

import java.net.URI;
import java.util.Optional;

import io.helidon.builder.api.Prototype;
Expand Down Expand Up @@ -81,6 +82,18 @@ public void decorate(McpSamplingRequest.BuilderBase<?, ?> builder, Optional<Doub
}
}

/**
* The URI scheme must be {@code file} when creating an MCP root.
*/
static class RootUriDecorator implements Prototype.OptionDecorator<McpRoot.BuilderBase<?, ?>, 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,39 +47,48 @@
* {@link io.helidon.extensions.mcp.server.McpSampling} - MCP Sampling feature.
* Send sampling messages to client.
* </li>
* <li>
* {@link io.helidon.extensions.mcp.server.McpRoots} - MCP Roots feature.
* List the available filesystem root from client.
* </li>
* </ul>
*/
public final class McpFeatures {
private final LazyValue<McpCancellation> 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;
}

/**
Expand Down Expand Up @@ -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.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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")
Expand All @@ -645,6 +656,29 @@ static JsonObject timeoutResponse(long requestId) {
return createJsonRpcErrorResponse(requestId, error);
}

static List<McpRoot> 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(Optional.ofNullable(root.getString("name", null)))
.build())
.toList();
}

private static McpSamplingMessage parseMessage(McpRole role, JsonObject object) {
String type = object.getString("type").toUpperCase();
McpSamplingMessageType messageType = McpSamplingMessageType.valueOf(type);
Expand Down
Loading