Skip to content

Commit

Permalink
Implement the resources server feature
Browse files Browse the repository at this point in the history
- Resource Templates are not supported yet
  • Loading branch information
mkouba committed Dec 18, 2024
1 parent 9433f62 commit 8ee8121
Show file tree
Hide file tree
Showing 41 changed files with 1,204 additions and 207 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import org.jboss.jandex.DotName;

import io.quarkiverse.mcp.server.BlobResourceContents;
import io.quarkiverse.mcp.server.Content;
import io.quarkiverse.mcp.server.ImageContent;
import io.quarkiverse.mcp.server.McpConnection;
Expand All @@ -12,8 +13,12 @@
import io.quarkiverse.mcp.server.PromptMessage;
import io.quarkiverse.mcp.server.PromptResponse;
import io.quarkiverse.mcp.server.RequestId;
import io.quarkiverse.mcp.server.Resource;
import io.quarkiverse.mcp.server.ResourceContent;
import io.quarkiverse.mcp.server.ResourceContents;
import io.quarkiverse.mcp.server.ResourceResponse;
import io.quarkiverse.mcp.server.TextContent;
import io.quarkiverse.mcp.server.TextResourceContents;
import io.quarkiverse.mcp.server.Tool;
import io.quarkiverse.mcp.server.ToolArg;
import io.quarkiverse.mcp.server.ToolResponse;
Expand Down Expand Up @@ -45,5 +50,11 @@ class DotNames {
static final DotName TEXT_CONTENT = DotName.createSimple(TextContent.class);
static final DotName IMAGE_CONTENT = DotName.createSimple(ImageContent.class);
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 STRING = DotName.createSimple(String.class);

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

import org.jboss.jandex.MethodInfo;

import io.quarkiverse.mcp.server.runtime.FeatureMetadata;
import io.quarkiverse.mcp.server.runtime.FeatureMetadata.Feature;
import io.quarkus.arc.processor.BeanInfo;
import io.quarkus.arc.processor.InvokerInfo;
import io.quarkus.builder.item.MultiBuildItem;
Expand All @@ -14,17 +16,24 @@ final class FeatureMethodBuildItem extends MultiBuildItem {
private final InvokerInfo invoker;
private final String name;
private final String description;

private final MethodInfo method;
private final Feature feature;

FeatureMethodBuildItem(BeanInfo bean, MethodInfo method, InvokerInfo invoker, String name, String description,
Feature feature) {
// Resource-only
private final String uri;
private final String mimeType;

FeatureMethodBuildItem(BeanInfo bean, MethodInfo method, InvokerInfo invoker, String name, String description, String uri,
String mimeType, FeatureMetadata.Feature feature) {
this.bean = Objects.requireNonNull(bean);
this.method = Objects.requireNonNull(method);
this.invoker = Objects.requireNonNull(invoker);
this.feature = Objects.requireNonNull(feature);
this.name = Objects.requireNonNull(name);
this.description = description;
this.feature = Objects.requireNonNull(feature);
this.uri = feature == Feature.RESOURCE ? Objects.requireNonNull(uri) : null;
this.mimeType = mimeType;
}

BeanInfo getBean() {
Expand All @@ -47,6 +56,14 @@ String getDescription() {
return description;
}

String getUri() {
return uri;
}

String getMimeType() {
return mimeType;
}

Feature getFeature() {
return feature;
}
Expand All @@ -59,15 +76,14 @@ boolean isPrompt() {
return feature == Feature.PROMPT;
}

boolean isResource() {
return feature == Feature.RESOURCE;
}

@Override
public String toString() {
return "FeatureMethodBuildItem [name=" + name + ", method=" + method.declaringClass() + "#" + method.name()
+ "(), feature=" + feature + "]";
}

enum Feature {
PROMPT,
TOOL
}

}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import org.jboss.resteasy.reactive.client.SseEvent;

import io.quarkus.rest.client.reactive.QuarkusRestClientBuilder;
import io.quarkus.test.QuarkusUnitTest;
import io.quarkus.test.common.http.TestHTTPResource;
import io.restassured.http.ContentType;
import io.vertx.core.json.JsonObject;
Expand All @@ -27,6 +28,15 @@ public abstract class McpServerTest {
@TestHTTPResource
URI testUri;

public static QuarkusUnitTest defaultConfig() {
QuarkusUnitTest config = new QuarkusUnitTest();
if (System.getProperty("logTraffic") != null) {
config.overrideConfigKey("quarkus.mcp.server.traffic-logging.enabled", "true");
config.overrideConfigKey("quarkus.log.category.\"io.quarkus.mcp.server.traffic\".level", "DEBUG");
}
return config;
}

protected List<SseEvent<String>> sseMessages;

AtomicInteger idGenerator = new AtomicInteger();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
public class CloseTest extends McpServerTest {

@RegisterExtension
static final QuarkusUnitTest config = new QuarkusUnitTest()
static final QuarkusUnitTest config = defaultConfig()
.withApplicationRoot(root -> root.addClasses(McpClient.class));

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
public class PingTest extends McpServerTest {

@RegisterExtension
static final QuarkusUnitTest config = new QuarkusUnitTest()
static final QuarkusUnitTest config = defaultConfig()
.withApplicationRoot(root -> root.addClasses(McpClient.class));

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
public class PromptsTest extends McpServerTest {

@RegisterExtension
static final QuarkusUnitTest config = new QuarkusUnitTest()
static final QuarkusUnitTest config = defaultConfig()
.withApplicationRoot(
root -> root.addClasses(McpClient.class, FooService.class, Options.class, Checks.class, MyPrompts.class));

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package io.quarkiverse.mcp.server.test.resources;

import static io.quarkiverse.mcp.server.test.Checks.checkDuplicatedContext;
import static io.quarkiverse.mcp.server.test.Checks.checkExecutionModel;
import static io.quarkiverse.mcp.server.test.Checks.checkRequestContext;

import java.util.List;

import io.quarkiverse.mcp.server.Resource;
import io.quarkiverse.mcp.server.ResourceResponse;
import io.quarkiverse.mcp.server.TextResourceContents;
import io.smallrye.mutiny.Uni;

public class MyResources {

@Resource(uri = "file:///project/alpha")
ResourceResponse alpha(String uri) {
checkExecutionModel(true);
checkDuplicatedContext();
checkRequestContext();
return new ResourceResponse(List.of(new TextResourceContents(uri, "1", null)));
}

@Resource(uri = "file:///project/uni_alpha")
Uni<ResourceResponse> uni_alpha(String uri) {
checkExecutionModel(false);
checkDuplicatedContext();
checkRequestContext();
return Uni.createFrom().item(new ResourceResponse(List.of(new TextResourceContents(uri, "2", null))));
}

@Resource(uri = "file:///project/bravo")
TextResourceContents bravo(String uri) {
checkExecutionModel(true);
checkDuplicatedContext();
checkRequestContext();
return new TextResourceContents(uri, "3", null);
}

@Resource(uri = "file:///project/uni_bravo")
Uni<TextResourceContents> uni_bravo() {
checkExecutionModel(false);
checkDuplicatedContext();
checkRequestContext();
return Uni.createFrom().item(new TextResourceContents("file:///foo", "4", null));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package io.quarkiverse.mcp.server.test.resources;

import static io.restassured.RestAssured.given;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;

import java.net.URI;
import java.net.URISyntaxException;

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkiverse.mcp.server.test.Checks;
import io.quarkiverse.mcp.server.test.McpClient;
import io.quarkiverse.mcp.server.test.McpServerTest;
import io.quarkus.test.QuarkusUnitTest;
import io.restassured.http.ContentType;
import io.vertx.core.json.JsonArray;
import io.vertx.core.json.JsonObject;

public class ResourcesTest extends McpServerTest {

@RegisterExtension
static final QuarkusUnitTest config = defaultConfig()
.withApplicationRoot(
root -> root.addClasses(McpClient.class, MyResources.class, Checks.class));

@Test
public void testResources() throws URISyntaxException {
URI endpoint = initClient();

JsonObject resourcesListMessage = newMessage("resources/list");

JsonObject resourcesListResponse = new JsonObject(given()
.contentType(ContentType.JSON)
.when()
.body(resourcesListMessage.encode())
.post(endpoint)
.then()
.statusCode(200)
.extract().body().asString());

JsonObject resourcesListResult = assertResponseMessage(resourcesListMessage, resourcesListResponse);
assertNotNull(resourcesListResult);
JsonArray resources = resourcesListResult.getJsonArray("resources");
assertEquals(4, resources.size());

// alpha, bravo, uni_alpha, uni_bravo
assertResource(resources.getJsonObject(0), "alpha", null, "file:///project/alpha", null);
assertResource(resources.getJsonObject(1), "bravo", null, "file:///project/bravo", null);
assertResource(resources.getJsonObject(2), "uni_alpha", null, "file:///project/uni_alpha", null);
assertResource(resources.getJsonObject(3), "uni_bravo", null, "file:///project/uni_bravo", null);

assertResourceRead("1", "file:///project/alpha", endpoint, "file:///project/alpha");
assertResourceRead("2", "file:///project/uni_alpha", endpoint, "file:///project/uni_alpha");
assertResourceRead("3", "file:///project/bravo", endpoint, "file:///project/bravo");
assertResourceRead("4", "file:///foo", endpoint, "file:///project/uni_bravo");
}

private void assertResource(JsonObject resource, String name, String description, String uri, String mimeType) {
assertEquals(name, resource.getString("name"));
if (description != null) {
assertEquals(description, resource.getString("description"));
}
assertEquals(uri, resource.getString("uri"));
if (mimeType != null) {
assertEquals(description, resource.getString("mimeType"));
}
}

private void assertResourceRead(String expectedText, String expectedUri, URI endpoint, String uri) {
JsonObject resourceReadMessage = newMessage("resources/read")
.put("params", new JsonObject()
.put("uri", uri));

JsonObject resourceReadResponse = new JsonObject(given()
.contentType(ContentType.JSON)
.when()
.body(resourceReadMessage.encode())
.post(endpoint)
.then()
.statusCode(200)
.extract().body().asString());

JsonObject resourceReadResult = assertResponseMessage(resourceReadMessage, resourceReadResponse);
assertNotNull(resourceReadResult);
JsonArray contents = resourceReadResult.getJsonArray("contents");
assertEquals(1, contents.size());
JsonObject textContent = contents.getJsonObject(0);
assertEquals(expectedText, textContent.getString("text"));
assertEquals(expectedUri, textContent.getString("uri"));
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@ public class CustomServerInfoTest extends McpServerTest {
private static final String VERSION = "1.0";

@RegisterExtension
static final QuarkusUnitTest config = new QuarkusUnitTest()
static final QuarkusUnitTest config = defaultConfig()
.withApplicationRoot(root -> root.addClasses(McpClient.class))
.overrideConfigKey("quarkus.mcp-server.server-info.name", NAME)
.overrideConfigKey("quarkus.mcp-server.server-info.version", VERSION);
.overrideConfigKey("quarkus.mcp.server.server-info.name", NAME)
.overrideConfigKey("quarkus.mcp.server.server-info.version", VERSION);

@Test
public void testServerInfo() throws URISyntaxException {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
public class DefaultServerInfoTest extends McpServerTest {

@RegisterExtension
static final QuarkusUnitTest config = new QuarkusUnitTest()
static final QuarkusUnitTest config = defaultConfig()
.withApplicationRoot(root -> root.addClasses(McpClient.class));

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
public class ToolsTest extends McpServerTest {

@RegisterExtension
static final QuarkusUnitTest config = new QuarkusUnitTest()
static final QuarkusUnitTest config = defaultConfig()
.withApplicationRoot(
root -> root.addClasses(McpClient.class, FooService.class, Options.class, Checks.class, MyTools.class));

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package io.quarkiverse.mcp.server.test.validation;

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

import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.RegisterExtension;

import io.quarkiverse.mcp.server.Resource;
import io.quarkiverse.mcp.server.ResourceResponse;
import io.quarkus.test.QuarkusUnitTest;

public class DuplicateResourceUriTest {

@RegisterExtension
public static final QuarkusUnitTest test = new QuarkusUnitTest()
.withApplicationRoot(root -> {
root.addClasses(InvalidResources.class);
})
.setExpectedException(IllegalStateException.class, true);

@Test
public void test() {
fail();
}

public static class InvalidResources {

@Resource(uri = "baz")
ResourceResponse foo() {
return null;
}

@Resource(uri = "baz")
ResourceResponse foos() {
return null;
}

}

}
Loading

0 comments on commit 8ee8121

Please sign in to comment.