Skip to content

Commit

Permalink
refactor(kubernetes): test uses SSE MCP client
Browse files Browse the repository at this point in the history
Improves capabilities for debugging serialization and other Quarkus MCP server extension features.
  • Loading branch information
manusa committed Feb 10, 2025
1 parent a7d7f41 commit a573121
Show file tree
Hide file tree
Showing 6 changed files with 101 additions and 44 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
import java.util.Map;
import java.util.concurrent.TimeUnit;

@SuppressWarnings("unused")
@ApplicationScoped
public class MCPServerKubernetes {

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
import java.util.HashMap;
import java.util.List;

import static io.quarkus.mcp.servers.kubernetes.MCPTestUtils.initMcpClient;
import static io.quarkus.mcp.servers.kubernetes.MCPTestUtils.initMcpStdioClient;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;

Expand All @@ -41,7 +41,7 @@ static void setUp() throws Exception {
new MockWebServer(), new HashMap<>(), new KubernetesCrudDispatcher(), true);
mockServer.init();
kubernetesClient = mockServer.createClient();
client = initMcpClient(kubernetesClient.getConfiguration().getMasterUrl());
client = initMcpStdioClient(kubernetesClient.getConfiguration().getMasterUrl());
}

@AfterAll
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
package io.quarkus.mcp.servers.kubernetes;

import dev.langchain4j.agent.tool.ToolExecutionRequest;
import dev.langchain4j.mcp.client.DefaultMcpClient;
import dev.langchain4j.mcp.client.McpClient;
import dev.langchain4j.mcp.client.transport.http.HttpMcpTransport;
import io.fabric8.kubernetes.api.model.ConfigMapBuilder;
import io.fabric8.kubernetes.api.model.GenericKubernetesResource;
import io.fabric8.kubernetes.api.model.NamespaceBuilder;
import io.fabric8.kubernetes.api.model.NodeBuilder;
import io.fabric8.kubernetes.api.model.ObjectMetaBuilder;
import io.fabric8.kubernetes.api.model.ServiceAccountBuilder;
import io.fabric8.kubernetes.client.Config;
import io.fabric8.kubernetes.client.KubernetesClient;
import io.fabric8.kubernetes.client.dsl.NonDeletingOperation;
import io.quarkus.test.common.http.TestHTTPResource;
import io.quarkus.test.junit.QuarkusTest;
import jakarta.inject.Inject;
import org.junit.jupiter.api.BeforeEach;
Expand All @@ -15,6 +21,9 @@
import org.junit.jupiter.api.condition.DisabledOnOs;
import org.junit.jupiter.api.condition.OS;

import java.net.URL;
import java.time.Duration;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.TimeUnit;

Expand All @@ -30,13 +39,24 @@ class MCPServerKubernetesTest {

@Inject
KubernetesClient kubernetesClient;
@Inject
MCPServerKubernetes server;
@TestHTTPResource
URL url;
private McpClient mcpClient;

@BeforeEach
void setUpMcpClient() {
mcpClient = new DefaultMcpClient.Builder()
.clientName("test-mcp-client-kubernetes")
.toolExecutionTimeout(Duration.ofSeconds(10))
.transport(new HttpMcpTransport.Builder().sseUrl(url.toString() + "mcp/sse").build())
.build();
}

@Test
void configuration_get_returnsTestKubernetesMasterUrl() {
assertThat(server.configuration_get())
.extracting(Config::getMasterUrl).asString()
final var ret = mcpClient.executeTool(ToolExecutionRequest.builder().name("configuration_get").arguments("{}").build());
assertThat(unmarshal(ret))
.extracting(gkr -> gkr.get("masterUrl")).asString()
.startsWith("https://localhost:");
}

Expand All @@ -45,12 +65,19 @@ class GenericResourceOperations {

@Test
void resources_list_clusterScopedWithIgnoredNamespace() {
kubernetesClient.nodes()
.resource(new NodeBuilder().withNewMetadata().withName("a-node-to-list").endMetadata().build())
.serverSideApply();
assertThat(server.resources_list("v1", "Node", "ignored"))
for (int it = 1; it <= 2; it++) {
kubernetesClient.nodes()
.resource(new NodeBuilder().withMetadata(new ObjectMetaBuilder()
.withName("a-node-to-list-" + it)
.addNewManagedField().withManager("the-manager").endManagedField()
.build()).build())
.serverSideApply();
}
final var ret = mcpClient.executeTool(ToolExecutionRequest.builder().name("resources_list")
.arguments("{\"apiVersion\":\"v1\",\"kind\":\"Node\",\"namespace\":\"ignored\"}").build());
assertThat(unmarshalList(ret))
.extracting("kind", "metadata.name")
.contains(tuple("Node", "a-node-to-list"));
.contains(tuple("Node", "a-node-to-list-1"), tuple("Node", "a-node-to-list-2"));
}

@Test
Expand All @@ -65,7 +92,9 @@ void resources_list_namespaceScopedAllNamespaces() {
.inNamespace("other-namespace")
.resource(new ConfigMapBuilder().withNewMetadata().withName("a-configmap-to-list-in-other-namespace").endMetadata().build())
.serverSideApply();
assertThat(server.resources_list("v1", "ConfigMap", null))
final var ret = mcpClient.executeTool(ToolExecutionRequest.builder().name("resources_list")
.arguments("{\"apiVersion\":\"v1\",\"kind\":\"ConfigMap\"}").build());
assertThat(unmarshalList(ret))
.extracting("kind", "metadata.namespace", "metadata.name")
.contains(
tuple("ConfigMap", "default", "a-configmap-to-list"),
Expand All @@ -78,7 +107,9 @@ void resources_get_clusterScopedWithIgnoredNamespace() {
kubernetesClient.nodes()
.resource(new NodeBuilder().withNewMetadata().withName("a-node-to-get").endMetadata().build())
.serverSideApply();
assertThat(server.resources_get("v1", "Node", "ignored", "a-node-to-get"))
final var ret = mcpClient.executeTool(ToolExecutionRequest.builder().name("resources_get")
.arguments("{\"apiVersion\":\"v1\",\"kind\":\"Node\",\"namespace\":\"ignored\",\"name\":\"a-node-to-get\"}").build());
assertThat(unmarshal(ret))
.hasFieldOrPropertyWithValue("metadata.name", "a-node-to-get");
}

Expand All @@ -87,13 +118,17 @@ void resources_get_namespaceScoped() {
kubernetesClient.configMaps()
.resource(new ConfigMapBuilder().withNewMetadata().withName("a-configmap-to-get").endMetadata().build())
.serverSideApply();
assertThat(server.resources_get("v1", "ConfigMap", "default", "a-configmap-to-get"))
final var ret = mcpClient.executeTool(ToolExecutionRequest.builder().name("resources_get")
.arguments("{\"apiVersion\":\"v1\",\"kind\":\"ConfigMap\",\"namespace\":\"default\",\"name\":\"a-configmap-to-get\"}").build());
assertThat(unmarshal(ret))
.hasFieldOrPropertyWithValue("metadata.name", "a-configmap-to-get");
}

@Test
void resources_create_or_update_clusterScopedWithIgnoredNamespace() {
assertThat(server.resources_create_or_update("{\"apiVersion\":\"v1\",\"kind\":\"Node\",\"metadata\":{\"name\":\"a-node-to-create\"}}"))
final var ret = mcpClient.executeTool(ToolExecutionRequest.builder().name("resources_create_or_update")
.arguments("{\"resource\":\"{\\\"apiVersion\\\":\\\"v1\\\",\\\"kind\\\":\\\"Node\\\",\\\"metadata\\\":{\\\"name\\\":\\\"a-node-to-create\\\"}}\"}").build());
assertThat(unmarshal(ret))
.hasFieldOrPropertyWithValue("apiVersion", "v1")
.hasFieldOrPropertyWithValue("kind", "Node")
.hasFieldOrPropertyWithValue("metadata.name", "a-node-to-create");
Expand All @@ -103,11 +138,12 @@ void resources_create_or_update_clusterScopedWithIgnoredNamespace() {

@Test
void resources_create_or_update_namespaceScoped() {
assertThat(server.resources_create_or_update("{\"apiVersion\":\"v1\",\"kind\":\"ConfigMap\",\"metadata\":{\"name\":\"a-configmap-to-create\"}}"))
final var ret = mcpClient.executeTool(ToolExecutionRequest.builder().name("resources_create_or_update")
.arguments("{\"resource\":\"{\\\"apiVersion\\\":\\\"v1\\\",\\\"kind\\\":\\\"ConfigMap\\\",\\\"metadata\\\":{\\\"name\\\":\\\"a-configmap-to-create\\\"}}\"}").build());
assertThat(unmarshal(ret))
.hasFieldOrPropertyWithValue("apiVersion", "v1")
.hasFieldOrPropertyWithValue("kind", "ConfigMap")
.hasFieldOrPropertyWithValue("metadata.name", "a-configmap-to-create");
;
assertThat(kubernetesClient.configMaps().inNamespace("default").withName("a-configmap-to-create").get())
.hasFieldOrPropertyWithValue("metadata.name", "a-configmap-to-create");
}
Expand All @@ -117,7 +153,9 @@ void resources_delete_clusterScopedWithIgnoredNamespace() {
kubernetesClient.nodes()
.resource(new NodeBuilder().withNewMetadata().withName("a-node-to-delete").endMetadata().build())
.serverSideApply();
assertThat(server.resources_delete("v1", "Node", "ignored", "a-node-to-delete"))
final var ret = mcpClient.executeTool(ToolExecutionRequest.builder().name("resources_delete")
.arguments("{\"apiVersion\":\"v1\",\"kind\":\"Node\",\"namespace\":\"ignored\",\"name\":\"a-node-to-delete\"}").build());
assertThat(ret)
.isEqualTo("Resource deleted successfully");
}

Expand All @@ -126,7 +164,9 @@ void resources_delete_namespaceScoped() {
kubernetesClient.configMaps()
.resource(new ConfigMapBuilder().withNewMetadata().withName("a-configmap-to-delete").endMetadata().build())
.serverSideApply();
assertThat(server.resources_delete("v1", "ConfigMap", "default", "a-configmap-to-delete"))
final var ret = mcpClient.executeTool(ToolExecutionRequest.builder().name("resources_delete")
.arguments("{\"apiVersion\":\"v1\",\"kind\":\"Node\",\"namespace\":\"default\",\"name\":\"a-configmap-to-delete\"}").build());
assertThat(ret)
.isEqualTo("Resource deleted successfully");
}
}
Expand All @@ -136,7 +176,8 @@ void namespaces_list() {
kubernetesClient.namespaces()
.resource(new NamespaceBuilder().withNewMetadata().withName("a-namespace-to-list").endMetadata().build())
.serverSideApply();
assertThat(server.namespaces_list())
final var ret = mcpClient.executeTool(ToolExecutionRequest.builder().name("namespaces_list").arguments("{}").build());
assertThat(unmarshalList(ret))
.extracting("metadata.name")
.contains("a-namespace-to-list");
}
Expand All @@ -157,7 +198,8 @@ void pods_list() {
.withName("a-pod-to-list")
.withImage("busybox")
.done();
assertThat(server.pods_list())
final var ret = mcpClient.executeTool(ToolExecutionRequest.builder().name("pods_list").arguments("{}").build());
assertThat(unmarshalList(ret))
.extracting("metadata.name")
.contains("a-pod-to-list");
}
Expand All @@ -168,7 +210,9 @@ void pods_list_in_namespace() {
.withName("a-pod-to-list-in-namespace")
.withImage("busybox")
.done();
assertThat(server.pods_list_in_namespace("default"))
final var ret = mcpClient.executeTool(ToolExecutionRequest.builder().name("pods_list")
.arguments("{\"namespace\":\"default\"}").build());
assertThat(unmarshalList(ret))
.extracting("metadata.name")
.contains("a-pod-to-list-in-namespace");
}
Expand All @@ -179,7 +223,9 @@ void pods_get() {
.withName("a-pod-to-get")
.withImage("busybox")
.done();
assertThat(server.pods_get(null, "a-pod-to-get"))
final var ret = mcpClient.executeTool(ToolExecutionRequest.builder().name("pods_get")
.arguments("{\"name\":\"a-pod-to-get\"}").build());
assertThat(unmarshal(ret))
.extracting("metadata.name")
.isEqualTo("a-pod-to-get");
}
Expand All @@ -190,7 +236,9 @@ void pods_delete() {
.withName("a-pod-to-delete")
.withImage("busybox")
.done();
assertThat(server.pods_delete(null, "a-pod-to-delete"))
final var ret = mcpClient.executeTool(ToolExecutionRequest.builder().name("pods_delete")
.arguments("{\"name\":\"a-pod-to-delete\"}").build());
assertThat(ret)
.isEqualTo("Pod deleted successfully");
}

Expand All @@ -200,13 +248,16 @@ void pods_log() {
.withName("a-pod-to-log")
.withImage("busybox")
.done();
assertThat(server.pods_log("default", "a-pod-to-log"))
final var ret = mcpClient.executeTool(ToolExecutionRequest.builder().name("pods_log")
.arguments("{\"namespace\":\"default\",\"name\":\"a-pod-to-log\"}").build());
assertThat(ret)
.isBlank();
}

@Test
void pods_run_startsPod() {
server.pods_run("default", "a-pod-to-run", "busybox", null);
mcpClient.executeTool(ToolExecutionRequest.builder().name("pods_run")
.arguments("{\"namespace\":\"default\",\"name\":\"a-pod-to-run\",\"image\":\"busybox\"}").build());
assertThat(kubernetesClient.pods().inNamespace("default").withName("a-pod-to-run")
.waitUntilCondition(Objects::nonNull, 10, TimeUnit.SECONDS))
.isNotNull()
Expand All @@ -215,7 +266,9 @@ void pods_run_startsPod() {

@Test
void pods_run_returnsPodInfo() {
assertThat(server.pods_run("default", "a-pod-to-run-2", "busybox", null))
final var ret = mcpClient.executeTool(ToolExecutionRequest.builder().name("pods_run")
.arguments("{\"namespace\":\"default\",\"name\":\"a-pod-to-run-2\",\"image\":\"busybox\"}").build());
assertThat(unmarshalList(ret))
.extracting("kind", "metadata.name")
.contains(
tuple("Pod", "a-pod-to-run-2")
Expand All @@ -224,12 +277,23 @@ void pods_run_returnsPodInfo() {

@Test
void pods_run_returnsServiceInfo() {
assertThat(server.pods_run("default", "a-pod-to-run-with-service", "busybox", 8080))
final var ret = mcpClient.executeTool(ToolExecutionRequest.builder().name("pods_run")
.arguments("{\"namespace\":\"default\",\"name\":\"a-pod-to-run-with-service\",\"image\":\"busybox\",\"port\":8080}").build());
assertThat(unmarshalList(ret))
.extracting("kind", "metadata.name")
.contains(
tuple("Pod", "a-pod-to-run-with-service"),
tuple("Service", "a-pod-to-run-with-service")
);
}
}

@SuppressWarnings("unchecked")
private List<GenericKubernetesResource> unmarshalList(String json) {
return kubernetesClient.getKubernetesSerialization().unmarshal(json, List.class);
}

private GenericKubernetesResource unmarshal(String json) {
return kubernetesClient.getKubernetesSerialization().unmarshal(json, GenericKubernetesResource.class);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public class MCPTestUtils {
private MCPTestUtils() {
}

public static McpClient initMcpClient(String masterUrl) throws ReflectiveOperationException {
public static McpClient initMcpStdioClient(String masterUrl) {
final var kubeConfigArgs = List.of(
"-Dquarkus.kubernetes-client.api-server-url=" + masterUrl,
"-Dquarkus.kubernetes-client.trust-certs=true",
Expand All @@ -29,18 +29,10 @@ public static McpClient initMcpClient(String masterUrl) throws ReflectiveOperati
command.add("-jar");
command.add(System.getProperty("java.jar.path"));
}
final var transport = new StdioMcpTransport.Builder().command(command).logEvents(true).build();
final var client = new DefaultMcpClient.Builder()
return new DefaultMcpClient.Builder()
.clientName("test-mcp-client-kubernetes")
.toolExecutionTimeout(Duration.ofSeconds(10))
.transport(transport)
.transport(new StdioMcpTransport.Builder().command(command).logEvents(true).build())
.build();
// TODO: Remove once LangChain4J is fixed (1.0.0-alpha2)
// https://github.com/langchain4j/langchain4j/pull/2360
// https://github.com/langchain4j/langchain4j/issues/2341#issuecomment-2564081377
final var execute = StdioMcpTransport.class.getDeclaredMethod("execute", String.class, Long.class);
execute.setAccessible(true);
execute.invoke(transport, "{\"jsonrpc\":\"2.0\",\"method\":\"notifications/initialized\"}", 1000L);
return client;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
import java.util.Map;
import java.util.Queue;

import static io.quarkus.mcp.servers.kubernetes.MCPTestUtils.initMcpClient;
import static io.quarkus.mcp.servers.kubernetes.MCPTestUtils.initMcpStdioClient;
import static org.assertj.core.api.Assertions.assertThat;

public class ObjectMapperCustomizerIT {
Expand All @@ -38,7 +38,7 @@ static void setUp() throws Exception {
new MockWebServer(), responses, new KubernetesMixedDispatcher(responses), true);
mockServer.init();
kubernetesClient = mockServer.createClient();
client = initMcpClient(kubernetesClient.getConfiguration().getMasterUrl());
client = initMcpStdioClient(kubernetesClient.getConfiguration().getMasterUrl());
}

@AfterAll
Expand Down
4 changes: 2 additions & 2 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@
<mcp.server.version>999-SNAPSHOT</mcp.server.version>
-->
<mcp.server.version>1.0.0.Beta1</mcp.server.version>
<langchain4j.version>1.0.0-alpha1</langchain4j.version>
<langchain4j.version>1.0.0-beta1</langchain4j.version>

</properties>

Expand All @@ -54,7 +54,7 @@
<version>${assertj.version}</version>
<scope>test</scope>
</dependency>

</dependencies>
</dependencyManagement>

Expand Down

0 comments on commit a573121

Please sign in to comment.