Skip to content

Commit

Permalink
Merge pull request #59 from mkouba/resource-templates
Browse files Browse the repository at this point in the history
Implement resource templates
  • Loading branch information
mkouba authored Jan 15, 2025
2 parents 5060aed + b756447 commit 3febd2e
Show file tree
Hide file tree
Showing 23 changed files with 578 additions and 147 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@
import io.quarkiverse.mcp.server.ResourceContent;
import io.quarkiverse.mcp.server.ResourceContents;
import io.quarkiverse.mcp.server.ResourceResponse;
import io.quarkiverse.mcp.server.ResourceTemplate;
import io.quarkiverse.mcp.server.ResourceTemplateArg;
import io.quarkiverse.mcp.server.TextContent;
import io.quarkiverse.mcp.server.TextResourceContents;
import io.quarkiverse.mcp.server.Tool;
Expand Down Expand Up @@ -55,12 +57,14 @@ class DotNames {
static final DotName RESOURCE_CONTENT = DotName.createSimple(ResourceContent.class);
static final DotName RESOURCE = DotName.createSimple(Resource.class);
static final DotName RESOURCE_RESPONSE = DotName.createSimple(ResourceResponse.class);
static final DotName RESOURCE_CONTENS = DotName.createSimple(ResourceContents.class);
static final DotName TEXT_RESOURCE_CONTENS = DotName.createSimple(TextResourceContents.class);
static final DotName BLOB_RESOURCE_CONTENS = DotName.createSimple(BlobResourceContents.class);
static final DotName RESOURCE_CONTENTS = DotName.createSimple(ResourceContents.class);
static final DotName TEXT_RESOURCE_CONTENTS = DotName.createSimple(TextResourceContents.class);
static final DotName BLOB_RESOURCE_CONTENTS = DotName.createSimple(BlobResourceContents.class);
static final DotName STRING = DotName.createSimple(String.class);
static final DotName COMPLETE_PROMPT = DotName.createSimple(CompletePrompt.class);
static final DotName COMPLETE_ARG = DotName.createSimple(CompleteArg.class);
static final DotName COMPLETE_RESPONSE = DotName.createSimple(CompletionResponse.class);
static final DotName RESOURCE_TEMPLATE = DotName.createSimple(ResourceTemplate.class);
static final DotName RESOURCE_TEMPLATE_ARG = DotName.createSimple(ResourceTemplateArg.class);

}
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ final class FeatureMethodBuildItem extends MultiBuildItem {
this.feature = Objects.requireNonNull(feature);
this.name = Objects.requireNonNull(name);
this.description = description;
this.uri = feature == Feature.RESOURCE ? Objects.requireNonNull(uri) : null;
this.uri = feature.requiresUri() ? Objects.requireNonNull(uri) : null;
this.mimeType = mimeType;
}

Expand Down Expand Up @@ -89,6 +89,10 @@ boolean isResource() {
return feature == Feature.RESOURCE;
}

boolean isResourceTemplate() {
return feature == Feature.RESOURCE_TEMPLATE;
}

@Override
public String toString() {
return "FeatureMethodBuildItem [name=" + name + ", method=" + method.declaringClass() + "#"
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package io.quarkiverse.mcp.server.deployment;

import static org.junit.jupiter.api.Assertions.assertEquals;

import org.jboss.jandex.ClassType;
import org.jboss.jandex.ParameterizedType;
import org.junit.jupiter.api.Test;

import io.quarkiverse.mcp.server.runtime.FeatureMetadata.Feature;

public class McpServerProcessorTest {

@Test
public void testCreateMapperField() {
assertEquals("RESOURCE_CONTENT",
McpServerProcessor.createMapperField(Feature.RESOURCE, ClassType.create(DotNames.TEXT_RESOURCE_CONTENTS),
DotNames.RESOURCE_RESPONSE,
c -> "CONTENT"));
assertEquals("IDENTITY",
McpServerProcessor.createMapperField(Feature.RESOURCE,
ParameterizedType.create(DotNames.UNI, ClassType.create(DotNames.RESOURCE_RESPONSE)),
DotNames.RESOURCE_RESPONSE,
c -> "CONTENT"));
}

}
5 changes: 5 additions & 0 deletions core/runtime/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@
<artifactId>jsonschema-generator</artifactId>
<version>${jsonschema-generator.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package io.quarkiverse.mcp.server;

import static java.lang.annotation.ElementType.METHOD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;

import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import java.util.List;

import io.smallrye.mutiny.Uni;

/**
* Annotates a business method of a CDI bean as an exposed resource template.
* <p>
* The result of a "resource read" operation is always represented as a {@link ResourceResponse}. However, the annotated method
* can also return other types that are converted according to the following rules.
*
* <ul>
* <li>If the method returns an implementation of {@link ResourceContents} then the reponse contains the single contents
* object.</li>
* <li>If the method returns a {@link List} of {@link ResourceContents} implementations then the reponse contains the list of
* contents objects.</li>
* <li>The method may return a {@link Uni} that wraps any of the type mentioned above.</li>
* </ul>
*/
@Retention(RUNTIME)
@Target(METHOD)
public @interface ResourceTemplate {

/**
* Constant value for {@link #name()} indicating that the annotated element's name should be used as-is.
*/
String ELEMENT_NAME = "<<element name>>";

/**
* The human-readable name for this resource template.
*/
String name() default ELEMENT_NAME;

/**
* The description of what this resource template represents.
*/
String description() default "";

/**
* The Level 1 URI template that can be used to construct resource URIs.
* <p>
* See <a href="https://datatracker.ietf.org/doc/html/rfc6570#section-1.2">the RFC 6570</a> for syntax definition.
*/
String uriTemplate();

/**
* The MIME type of this resource template.
*/
String mimeType() default "";

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package io.quarkiverse.mcp.server;

import static java.lang.annotation.RetentionPolicy.RUNTIME;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;

/**
* Annotates a parameter of a {@link ResourceTemplate} method.
*/
@Retention(RUNTIME)
@Target(ElementType.PARAMETER)
public @interface ResourceTemplateArg {

/**
* Constant value for {@link #name()} indicating that the annotated element's name should be used as-is.
*/
String ELEMENT_NAME = "<<element name>>";

String name() default ELEMENT_NAME;

}
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public Future<R> execute(String id, ArgumentProviders argProviders) throws McpEx
throw notFound(id);
}
Invoker<Object, Object> invoker = metadata.invoker();
Object[] arguments = prepareArguments(metadata.info(), argProviders);
Object[] arguments = prepareArguments(metadata, argProviders);
return execute(metadata.executionModel(), new Callable<Uni<R>>() {
@Override
public Uni<R> call() throws Exception {
Expand All @@ -63,13 +63,13 @@ public boolean isEmpty() {
}

@SuppressWarnings("unchecked")
private Object[] prepareArguments(FeatureMethodInfo info, ArgumentProviders argProviders) throws McpException {
if (info.arguments().isEmpty()) {
protected Object[] prepareArguments(FeatureMetadata<?> metadata, ArgumentProviders argProviders) throws McpException {
if (metadata.info().arguments().isEmpty()) {
return new Object[0];
}
Object[] ret = new Object[info.arguments().size()];
Object[] ret = new Object[metadata.info().arguments().size()];
int idx = 0;
for (FeatureArgument arg : info.arguments()) {
for (FeatureArgument arg : metadata.info().arguments()) {
if (arg.provider() == Provider.MCP_CONNECTION) {
ret[idx] = argProviders.connection();
} else if (arg.provider() == Provider.REQUEST_ID) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ public JsonObject asJson() {
} else if (feature == Feature.RESOURCE) {
json.put("uri", info.uri())
.put("mimeType", info.mimeType());
} else if (feature == Feature.RESOURCE_TEMPLATE) {
json.put("uriTemplate", info.uri())
.put("mimeType", info.mimeType());
}
return json;
}
Expand All @@ -41,7 +44,12 @@ public enum Feature {
PROMPT,
TOOL,
RESOURCE,
PROMPT_COMPLETE
RESOURCE_TEMPLATE,
PROMPT_COMPLETE;

public boolean requiresUri() {
return this == RESOURCE || this == RESOURCE_TEMPLATE;
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -22,26 +22,26 @@ public class McpMessageHandler {
protected final ConnectionManager connectionManager;

private final ToolMessageHandler toolHandler;

private final PromptMessageHandler promptHandler;

private final PromptCompletionMessageHandler promptCompleteHandler;

private final ResourceMessageHandler resourceHandler;
private final ResourceTemplateMessageHandler resourceTemplateHandler;

protected final McpRuntimeConfig config;

private final Map<String, Object> serverInfo;

protected McpMessageHandler(McpRuntimeConfig config, ConnectionManager connectionManager, PromptManager promptManager,
ToolManager toolManager, ResourceManager resourceManager, PromptCompleteManager promptCompleteManager) {
ToolManager toolManager, ResourceManager resourceManager, PromptCompleteManager promptCompleteManager,
ResourceTemplateManager resourceTemplateManager) {
this.connectionManager = connectionManager;
this.toolHandler = new ToolMessageHandler(toolManager);
this.promptHandler = new PromptMessageHandler(promptManager);
this.promptCompleteHandler = new PromptCompletionMessageHandler(promptCompleteManager);
this.resourceHandler = new ResourceMessageHandler(resourceManager);
this.resourceTemplateHandler = new ResourceTemplateMessageHandler(resourceTemplateManager);
this.config = config;
this.serverInfo = serverInfo(promptManager, toolManager, resourceManager);
this.serverInfo = serverInfo(promptManager, toolManager, resourceManager, resourceTemplateManager);
}

public void handle(JsonObject message, McpConnection connection, Responder responder) {
Expand Down Expand Up @@ -99,6 +99,7 @@ private void initializing(JsonObject message, Responder responder, McpConnection
private static final String TOOLS_LIST = "tools/list";
private static final String TOOLS_CALL = "tools/call";
private static final String RESOURCES_LIST = "resources/list";
private static final String RESOURCE_TEMPLATES_LIST = "resources/templates/list";
private static final String RESOURCES_READ = "resources/read";
private static final String PING = "ping";
private static final String COMPLETION_COMPLETE = "completion/complete";
Expand All @@ -115,6 +116,7 @@ private void operation(JsonObject message, Responder responder, McpConnection co
case PING -> ping(message, responder);
case RESOURCES_LIST -> resourceHandler.resourcesList(message, responder);
case RESOURCES_READ -> resourceHandler.resourcesRead(message, responder, connection);
case RESOURCE_TEMPLATES_LIST -> resourceTemplateHandler.resourceTemplatesList(message, responder);
case COMPLETION_COMPLETE -> complete(message, responder, connection);
case Q_CLOSE -> close(message, responder, connection);
default -> responder.send(
Expand Down Expand Up @@ -180,7 +182,7 @@ private InitializeRequest decodeInitializeRequest(JsonObject params) {
}

private Map<String, Object> serverInfo(PromptManager promptManager, ToolManager toolManager,
ResourceManager resourceManager) {
ResourceManager resourceManager, ResourceTemplateManager resourceTemplateManager) {
Map<String, Object> info = new HashMap<>();
info.put("protocolVersion", "2024-11-05");

Expand All @@ -197,7 +199,7 @@ private Map<String, Object> serverInfo(PromptManager promptManager, ToolManager
if (!toolManager.isEmpty()) {
capabilities.put("tools", Map.of());
}
if (!resourceManager.isEmpty()) {
if (!resourceManager.isEmpty() || resourceTemplateManager.isEmpty()) {
capabilities.put("resources", Map.of());
}
info.put("capabilities", capabilities);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,4 +17,6 @@ public interface McpMetadata {

List<FeatureMetadata<ResourceResponse>> resources();

List<FeatureMetadata<ResourceResponse>> resourceTemplates();

}
Original file line number Diff line number Diff line change
Expand Up @@ -10,21 +10,43 @@
import com.fasterxml.jackson.databind.ObjectMapper;

import io.quarkiverse.mcp.server.ResourceResponse;
import io.quarkiverse.mcp.server.runtime.FeatureMetadata.Feature;
import io.vertx.core.Vertx;

@Singleton
public class ResourceManager extends FeatureManager<ResourceResponse> {

final ResourceTemplateManager resourceTemplateManager;

final Map<String, FeatureMetadata<ResourceResponse>> resources;

ResourceManager(McpMetadata metadata, Vertx vertx, ObjectMapper mapper) {
ResourceManager(McpMetadata metadata, Vertx vertx, ObjectMapper mapper, ResourceTemplateManager resourceTemplateManager) {
super(vertx, mapper);
this.resourceTemplateManager = resourceTemplateManager;
this.resources = metadata.resources().stream().collect(Collectors.toMap(m -> m.info().uri(), Function.identity()));
}

@Override
protected FeatureMetadata<ResourceResponse> getMetadata(String identifier) {
return resources.get(identifier);
FeatureMetadata<ResourceResponse> ret = resources.get(identifier);
if (ret == null) {
ret = resourceTemplateManager.getMetadata(identifier);
}
return ret;
}

@Override
protected Object[] prepareArguments(FeatureMetadata<?> metadata, ArgumentProviders argProviders) throws McpException {
if (metadata.feature() == Feature.RESOURCE_TEMPLATE) {
// Use variable matching to extract method arguments
Map<String, Object> matchedVariables = resourceTemplateManager.getVariableMatcher(metadata.info().name())
.matchVariables(argProviders.args().get("uri").toString());
matchedVariables.putIfAbsent("uri", argProviders.args().get("uri"));
argProviders = new ArgumentProviders(
matchedVariables,
argProviders.connection(), argProviders.requestId());
}
return super.prepareArguments(metadata, argProviders);
}

/**
Expand Down
Loading

0 comments on commit 3febd2e

Please sign in to comment.