diff --git a/src/main/java/com/google/cloud/mcp/tool/ToolResult.java b/src/main/java/com/google/cloud/mcp/tool/ToolResult.java index 29b6a61..71faf59 100644 --- a/src/main/java/com/google/cloud/mcp/tool/ToolResult.java +++ b/src/main/java/com/google/cloud/mcp/tool/ToolResult.java @@ -29,6 +29,23 @@ @JsonIgnoreProperties(ignoreUnknown = true) public record ToolResult( @JsonProperty("content") List content, @JsonProperty("isError") boolean isError) { + + /** + * Returns the concatenated text content from all text items in this result, or an empty string if + * no text content is available. + * + * @return The concatenated text content. + */ + public String text() { + if (content == null) { + return ""; + } + return content.stream() + .filter(c -> "text".equals(c.type()) && c.text() != null) + .map(Content::text) + .collect(java.util.stream.Collectors.joining("\n")); + } + /** * Represents a single content item in a tool result. * diff --git a/src/test/java/com/google/cloud/mcp/e2e/McpToolboxClientE2ETest.java b/src/test/java/com/google/cloud/mcp/e2e/McpToolboxClientE2ETest.java new file mode 100644 index 0000000..609c9a2 --- /dev/null +++ b/src/test/java/com/google/cloud/mcp/e2e/McpToolboxClientE2ETest.java @@ -0,0 +1,634 @@ +/* + * Copyright 2026 Google LLC + * + * 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 com.google.cloud.mcp.e2e; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.cloud.mcp.McpToolboxClient; +import com.google.cloud.mcp.ProtocolVersion; +import com.google.cloud.mcp.tool.Tool; +import com.google.cloud.mcp.tool.ToolDefinition; +import com.google.cloud.mcp.tool.ToolResult; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.extension.RegisterExtension; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +@Timeout(value = 60, unit = TimeUnit.SECONDS) +class McpToolboxClientE2ETest { + + @RegisterExtension static ToolboxE2ESetup server = new ToolboxE2ESetup(); + + private McpToolboxClient client; + + @BeforeEach + void setUp() { + client = McpToolboxClient.builder().baseUrl(server.getBaseUrl()).build(); + } + + // --- Toolset Loading & Error Tests --- + + @Test + void testLoadToolsetSpecific() { + Map tools1 = client.loadToolset("my-toolset").join(); + assertEquals(1, tools1.size()); + assertTrue(tools1.containsKey("get-row-by-id")); + + Map tools2 = client.loadToolset("my-toolset-2").join(); + assertEquals(2, tools2.size()); + assertTrue(tools2.containsKey("get-n-rows")); + assertTrue(tools2.containsKey("get-row-by-id")); + } + + @Test + void testLoadToolsetDefault() { + Map tools = client.loadToolset().join(); + assertEquals(7, tools.size()); + assertTrue(tools.containsKey("get-row-by-content-auth")); + assertTrue(tools.containsKey("get-row-by-email-auth")); + assertTrue(tools.containsKey("get-row-by-id-auth")); + assertTrue(tools.containsKey("get-row-by-id")); + assertTrue(tools.containsKey("get-n-rows")); + assertTrue(tools.containsKey("search-rows")); + assertTrue(tools.containsKey("process-data")); + } + + @Test + void testLoadNonExistentToolset() { + CompletionException ex = + assertThrows( + CompletionException.class, + () -> { + client.loadToolset("non-existent-toolset").join(); + }); + assertNotNull(ex.getCause()); + assertTrue( + ex.getCause().getMessage().contains("toolset does not exist") + || ex.getCause().getMessage().contains("non-existent-toolset") + || ex.getCause().getMessage().contains("Toolset not found"), + "Unexpected cause: " + ex.getCause().getMessage()); + } + + @Test + void testLoadNonExistentTool() { + CompletionException ex = + assertThrows( + CompletionException.class, + () -> { + client.loadTool("non-existent-tool").join(); + }); + assertNotNull(ex.getCause()); + assertTrue( + ex.getCause().getMessage().contains("Tool not found: non-existent-tool"), + "Unexpected cause: " + ex.getCause().getMessage()); + } + + // --- Tool Invocation & Argument Validations --- + + @Test + void testRunTool() { + Tool tool = client.loadTool("get-n-rows").join(); + ToolResult result = tool.execute(Map.of("num_rows", "2")).join(); + + assertFalse(result.isError(), "Expected successful result, but got error: " + result.text()); + String output = result.text(); + assertTrue(output.contains("row1"), "Output didn't contain row1. Actual output: " + output); + assertTrue(output.contains("row2")); + assertFalse(output.contains("row3")); + } + + @Test + void testRunToolMissingRequiredParams() { + Tool tool = client.loadTool("get-n-rows").join(); + CompletionException ex = + assertThrows( + CompletionException.class, + () -> { + tool.execute(Map.of()).join(); + }); + assertNotNull(ex.getCause()); + assertTrue( + ex.getCause() instanceof IllegalArgumentException, + "Expected IllegalArgumentException but got: " + ex.getCause().getClass().getName()); + assertTrue( + ex.getCause().getMessage().contains("Missing required parameter 'num_rows'"), + "Unexpected message: " + ex.getCause().getMessage()); + } + + @Test + void testRunToolWrongParamType() { + Tool tool = client.loadTool("get-n-rows").join(); + CompletionException ex = + assertThrows( + CompletionException.class, + () -> { + tool.execute(Map.of("num_rows", 2)).join(); + }); + assertNotNull(ex.getCause()); + assertTrue( + ex.getCause() instanceof IllegalArgumentException, + "Expected IllegalArgumentException but got: " + ex.getCause().getClass().getName()); + assertTrue( + ex.getCause().getMessage().contains("expected type 'string'"), + "Unexpected message: " + ex.getCause().getMessage()); + } + + // --- Parameter Binding & Schema Pruning --- + + @Test + void testBindParams() { + Tool tool = client.loadTool("get-n-rows").join(); + Tool boundTool = tool.bindParam("num_rows", "3"); + + ToolResult result = boundTool.execute(Map.of()).join(); + String output = result.text(); + + assertTrue(output.contains("row1"), "Actual output: " + output); + assertTrue(output.contains("row2")); + assertTrue(output.contains("row3")); + assertFalse(output.contains("row4")); + } + + @Test + void testBindParamsCallable() { + Tool tool = client.loadTool("get-n-rows").join(); + Tool boundTool = tool.bindParam("num_rows", () -> "3"); + + ToolResult result = boundTool.execute(Map.of()).join(); + String output = result.text(); + + assertTrue(output.contains("row1"), "Actual output: " + output); + assertTrue(output.contains("row2")); + assertTrue(output.contains("row3")); + assertFalse(output.contains("row4")); + } + + @Test + void testBoundParamPruningSchema() { + Tool tool = client.loadTool("get-n-rows").join(); + boolean hadParam = + tool.definition().parameters() != null + && tool.definition().parameters().stream().anyMatch(p -> "num_rows".equals(p.name())); + assertTrue(hadParam, "Original tool definition should have 'num_rows' parameter"); + + Tool boundTool = tool.bindParam("num_rows", "3"); + boolean hasParamAfter = + boundTool.definition().parameters() != null + && boundTool.definition().parameters().stream() + .anyMatch(p -> "num_rows".equals(p.name())); + assertFalse(hasParamAfter, "Bound parameter 'num_rows' must be pruned from definition schema"); + + boolean originalStillHasParam = + tool.definition().parameters() != null + && tool.definition().parameters().stream().anyMatch(p -> "num_rows".equals(p.name())); + assertTrue( + originalStillHasParam, + "Original tool definition must still contain 'num_rows' to ensure immutability"); + } + + // --- Authentication & Claim Injections --- + + @Test + void testRunToolAuth() { + Tool tool = + client + .loadTool("get-row-by-id-auth") + .join() + .addAuthTokenGetter( + "my-test-auth", () -> CompletableFuture.completedFuture(server.getAuthToken1())); + + ToolResult result = tool.execute(Map.of("id", "2")).join(); + assertFalse(result.isError()); + String output = result.text(); + assertTrue(output.contains("row2")); + } + + @Test + void testRunToolWrongAuth() { + Tool tool = + client + .loadTool("get-row-by-id-auth") + .join() + .addAuthTokenGetter( + "my-test-auth", () -> CompletableFuture.completedFuture(server.getAuthToken2())); + + ToolResult result = tool.execute(Map.of("id", "2")).join(); + assertTrue(result.isError(), "Expected error for wrong auth. Actual output: " + result.text()); + assertTrue( + result.text().toLowerCase().contains("unauthorized"), "Actual output: " + result.text()); + } + + @Test + void testRunToolAuthWithoutProvidingAuth() { + Tool tool = client.loadTool("get-row-by-id-auth").join(); + // Running authenticated tool without adding auth token getter + ToolResult result = tool.execute(Map.of("id", "2")).join(); + assertTrue( + result.isError(), + "Expected error when invoking tool without auth token. Output: " + result.text()); + assertTrue( + result.text().toLowerCase().contains("unauthorized") || result.text().contains("401"), + "Expected unauthorized/401 error message. Actual output: " + result.text()); + } + + @Test + void testRunToolParamAuth() { + Tool tool = + client + .loadTool("get-row-by-email-auth") + .join() + .addAuthTokenGetter( + "my-test-auth", () -> CompletableFuture.completedFuture(server.getAuthToken1())); + + ToolResult result = tool.execute(Map.of()).join(); + assertFalse(result.isError(), "Expected success but got error: " + result.text()); + String output = result.text(); + assertTrue(output.contains("row4"), "Actual output: " + output); + assertTrue(output.contains("row5")); + assertTrue(output.contains("row6")); + } + + @Test + void testRunToolParamAuthNoField() { + Tool tool = + client + .loadTool("get-row-by-content-auth") + .join() + .addAuthTokenGetter( + "my-test-auth", () -> CompletableFuture.completedFuture(server.getAuthToken1())); + + ToolResult result = tool.execute(Map.of()).join(); + assertTrue(result.isError()); + assertTrue(result.text().contains("no field named row_data")); + } + + @Test + void testRunToolWithFailingTokenSupplier() { + Tool tool = + client + .loadTool("get-row-by-id-auth") + .join() + .addAuthTokenGetter( + "my-test-auth", + () -> CompletableFuture.failedFuture(new RuntimeException("Token unavailable"))); + + CompletionException ex = + assertThrows( + CompletionException.class, + () -> { + tool.execute(Map.of("id", "2")).join(); + }); + assertNotNull(ex.getCause()); + assertTrue( + ex.getCause().getMessage().contains("Token unavailable"), + "Unexpected cause: " + ex.getCause().getMessage()); + } + + // ========================================================================= + // 5. Optional & Default Parameters Suite (search-rows) + // ========================================================================= + + @Test + void testSearchRowsDefinitionSchema() { + Tool tool = client.loadTool("search-rows").join(); + assertEquals("search-rows", tool.name()); + assertNotNull(tool.definition()); + + boolean hasEmail = false; + boolean hasData = false; + boolean hasId = false; + + if (tool.definition().parameters() != null) { + for (ToolDefinition.Parameter p : tool.definition().parameters()) { + if ("email".equals(p.name())) { + hasEmail = true; + assertTrue(p.required(), "Parameter 'email' should be required"); + assertEquals("string", p.type()); + } else if ("data".equals(p.name())) { + hasData = true; + assertFalse(p.required(), "Parameter 'data' should be optional"); + assertEquals("string", p.type()); + } else if ("id".equals(p.name())) { + hasId = true; + assertFalse(p.required(), "Parameter 'id' should be optional"); + assertEquals("integer", p.type()); + } + } + } + assertTrue(hasEmail, "Missing required parameter 'email' in definition"); + assertTrue(hasData, "Missing optional parameter 'data' in definition"); + assertTrue(hasId, "Missing optional parameter 'id' in definition"); + } + + @Test + void testSearchRowsOmittingOptionals() { + Tool tool = client.loadTool("search-rows").join(); + ToolResult result = tool.execute(Map.of("email", "twishabansal@google.com")).join(); + + assertFalse(result.isError(), "Expected success: " + result.text()); + String output = result.text(); + assertTrue(output.contains("twishabansal@google.com"), "Output: " + output); + assertTrue(output.contains("row2"), "Output: " + output); + assertFalse(output.contains("row1"), "Output should not contain row1: " + output); + assertFalse(output.contains("row3"), "Output should not contain row3: " + output); + } + + @Test + void testSearchRowsWithAllParamsProvided() { + Tool tool = client.loadTool("search-rows").join(); + Map args = new HashMap<>(); + args.put("email", "twishabansal@google.com"); + args.put("data", "row3"); + args.put("id", 3L); + + ToolResult result = tool.execute(args).join(); + assertFalse(result.isError(), "Expected success: " + result.text()); + String output = result.text(); + assertTrue(output.contains("twishabansal@google.com")); + assertTrue(output.contains("row3")); + assertFalse(output.contains("row2")); + } + + @Test + void testSearchRowsWithNullOptionalParams() { + Tool tool = client.loadTool("search-rows").join(); + Map args = new HashMap<>(); + args.put("email", "twishabansal@google.com"); + args.put("data", null); + args.put("id", null); + + ToolResult result = tool.execute(args).join(); + assertFalse(result.isError(), "Expected success: " + result.text()); + String output = result.text(); + assertTrue(output.contains("twishabansal@google.com")); + assertTrue(output.contains("row2")); + } + + @Test + void testSearchRowsWithNullRequiredParam() { + Tool tool = client.loadTool("search-rows").join(); + Map args = new HashMap<>(); + args.put("email", null); + args.put("data", "row3"); + + CompletionException ex = + assertThrows( + CompletionException.class, + () -> { + tool.execute(args).join(); + }); + assertNotNull(ex.getCause()); + assertTrue(ex.getCause() instanceof IllegalArgumentException); + assertTrue(ex.getCause().getMessage().contains("Missing required parameter 'email'")); + } + + @Test + void testSearchRowsWithWrongParamType() { + Tool tool = client.loadTool("search-rows").join(); + Map args = new HashMap<>(); + args.put("email", "twishabansal@google.com"); + args.put("id", "not-an-integer"); + + CompletionException ex = + assertThrows( + CompletionException.class, + () -> { + tool.execute(args).join(); + }); + assertNotNull(ex.getCause()); + assertTrue(ex.getCause() instanceof IllegalArgumentException); + assertTrue(ex.getCause().getMessage().contains("expected type 'integer' but got 'String'")); + } + + @Test + void testSearchRowsMissingRequiredParam() { + Tool tool = client.loadTool("search-rows").join(); + CompletionException ex = + assertThrows( + CompletionException.class, + () -> { + tool.execute(Map.of("data", "row3")).join(); + }); + assertNotNull(ex.getCause()); + assertTrue( + ex.getCause() instanceof IllegalArgumentException, + "Expected IllegalArgumentException but got: " + ex.getCause().getClass().getName()); + assertTrue( + ex.getCause().getMessage().contains("Missing required parameter 'email'"), + "Unexpected message: " + ex.getCause().getMessage()); + } + + @Test + void testSearchRowsNonMatchingData() { + Tool tool = client.loadTool("search-rows").join(); + Map args = new HashMap<>(); + args.put("email", "twishabansal@google.com"); + args.put("id", 3L); + args.put("data", "row4"); + + ToolResult result = tool.execute(args).join(); + assertFalse(result.isError(), "Expected success: " + result.text()); + String output = result.text().trim(); + assertTrue( + output.isEmpty() || "null".equals(output), + "Expected empty or 'null' response for non-matching data, got: " + output); + assertFalse(output.contains("row1")); + assertFalse(output.contains("row2")); + assertFalse(output.contains("row3")); + } + + // ========================================================================= + // 6. Map & Structured Payloads Suite (process-data) + // ========================================================================= + + @Test + void testProcessDataDefinitionSchema() { + Tool tool = client.loadTool("process-data").join(); + assertEquals("process-data", tool.name()); + assertNotNull(tool.definition()); + + boolean hasExecutionContext = false; + boolean hasUserScores = false; + boolean hasFeatureFlags = false; + + if (tool.definition().parameters() != null) { + for (ToolDefinition.Parameter p : tool.definition().parameters()) { + if ("execution_context".equals(p.name())) { + hasExecutionContext = true; + assertTrue(p.required(), "Parameter 'execution_context' should be required"); + assertNotNull(p.type()); + assertTrue( + "object".equalsIgnoreCase(p.type()), + "Parameter 'execution_context' type should be 'object', got: " + p.type()); + } else if ("user_scores".equals(p.name())) { + hasUserScores = true; + assertTrue(p.required(), "Parameter 'user_scores' should be required"); + assertNotNull(p.type()); + assertTrue( + "object".equalsIgnoreCase(p.type()), + "Parameter 'user_scores' type should be 'object', got: " + p.type()); + } else if ("feature_flags".equals(p.name())) { + hasFeatureFlags = true; + assertFalse(p.required(), "Parameter 'feature_flags' should be optional"); + assertNotNull(p.type()); + assertTrue( + "object".equalsIgnoreCase(p.type()), + "Parameter 'feature_flags' type should be 'object', got: " + p.type()); + } + } + } + assertTrue(hasExecutionContext, "Missing required parameter 'execution_context' in definition"); + assertTrue(hasUserScores, "Missing required parameter 'user_scores' in definition"); + assertTrue(hasFeatureFlags, "Missing optional parameter 'feature_flags' in definition"); + } + + @Test + void testProcessDataWithMapParams() throws JsonProcessingException { + Tool tool = client.loadTool("process-data").join(); + Map execCtx = new LinkedHashMap<>(); + execCtx.put("env", "prod"); + execCtx.put("id", 1234); + execCtx.put("user", 1234.5); + + Map userScores = new LinkedHashMap<>(); + userScores.put("user1", 100); + userScores.put("user2", 200); + + Map featureFlags = new LinkedHashMap<>(); + featureFlags.put("new_feature", true); + + Map args = new LinkedHashMap<>(); + args.put("execution_context", execCtx); + args.put("user_scores", userScores); + args.put("feature_flags", featureFlags); + + ToolResult result = tool.execute(args).join(); + + assertFalse(result.isError(), "Expected success: " + result.text()); + String output = result.text(); + JsonNode root = new ObjectMapper().readTree(output); + JsonNode node = root.isArray() ? root.get(0) : root; + JsonNode dataNode = node.has("jsonb_build_object") ? node.get("jsonb_build_object") : node; + + assertEquals("prod", dataNode.path("execution_context").path("env").asText()); + assertEquals(1234, dataNode.path("execution_context").path("id").asInt()); + assertEquals(1234.5, dataNode.path("execution_context").path("user").asDouble(), 0.001); + assertEquals(100, dataNode.path("user_scores").path("user1").asInt()); + assertEquals(200, dataNode.path("user_scores").path("user2").asInt()); + assertTrue(dataNode.path("feature_flags").path("new_feature").asBoolean()); + } + + @Test + void testProcessDataOmittingOptionalMap() throws JsonProcessingException { + Tool tool = client.loadTool("process-data").join(); + Map execCtx = new LinkedHashMap<>(); + execCtx.put("env", "dev"); + + Map userScores = new LinkedHashMap<>(); + userScores.put("user3", 300); + + Map args = new LinkedHashMap<>(); + args.put("execution_context", execCtx); + args.put("user_scores", userScores); + + ToolResult result = tool.execute(args).join(); + + assertFalse(result.isError(), "Expected success: " + result.text()); + String output = result.text(); + JsonNode root = new ObjectMapper().readTree(output); + JsonNode node = root.isArray() ? root.get(0) : root; + JsonNode dataNode = node.has("jsonb_build_object") ? node.get("jsonb_build_object") : node; + + assertEquals("dev", dataNode.path("execution_context").path("env").asText()); + assertEquals(300, dataNode.path("user_scores").path("user3").asInt()); + assertTrue( + dataNode.path("feature_flags").isNull() || dataNode.path("feature_flags").isMissingNode(), + "Expected null feature_flags: " + output); + } + + @Test + void testProcessDataWithWrongMapValueType() { + Tool tool = client.loadTool("process-data").join(); + Map execCtx = new LinkedHashMap<>(); + execCtx.put("env", "staging"); + + CompletionException ex = + assertThrows( + CompletionException.class, + () -> { + tool.execute(Map.of("execution_context", execCtx, "user_scores", "not-a-map")).join(); + }); + assertNotNull(ex.getCause()); + assertTrue(ex.getCause() instanceof IllegalArgumentException); + assertTrue(ex.getCause().getMessage().contains("expected type 'object' but got 'String'")); + } + + // ========================================================================= + // 7. Transport Headers & Protocol Suite + // ========================================================================= + + @Test + void testClientWithCustomHeaders() { + Map customHeaders = + Map.of("X-Custom-Client-Header", "SDK-Java-Client", "X-Integration-Source", "TestSuite"); + + McpToolboxClient customHeaderClient = + McpToolboxClient.builder().baseUrl(server.getBaseUrl()).headers(customHeaders).build(); + + Tool tool = customHeaderClient.loadTool("get-n-rows").join(); + assertNotNull(tool); + ToolResult result = tool.execute(Map.of("num_rows", "1")).join(); + assertFalse(result.isError(), "Execution failed: " + result.text()); + assertTrue(result.text().contains("row1")); + } + + @ParameterizedTest + @EnumSource( + value = ProtocolVersion.class, + names = { + "VERSION_2024_11_05", + "VERSION_2025_03_26", + "VERSION_2025_06_18", + "VERSION_2025_11_25" + }) + void testClientWithExplicitProtocolVersions(ProtocolVersion version) { + McpToolboxClient versionedClient = + McpToolboxClient.builder().baseUrl(server.getBaseUrl()).protocolVersion(version).build(); + + Tool tool = versionedClient.loadTool("get-n-rows").join(); + assertNotNull(tool); + ToolResult result = tool.execute(Map.of("num_rows", "1")).join(); + assertFalse( + result.isError(), "Execution failed for protocol " + version + ": " + result.text()); + assertTrue(result.text().contains("row1"), "Expected row1 for protocol " + version); + } +} diff --git a/src/test/java/com/google/cloud/mcp/e2e/McpToolboxClientTest.java b/src/test/java/com/google/cloud/mcp/e2e/McpToolboxClientTest.java deleted file mode 100644 index 883557b..0000000 --- a/src/test/java/com/google/cloud/mcp/e2e/McpToolboxClientTest.java +++ /dev/null @@ -1,191 +0,0 @@ -/* - * Copyright 2026 Google LLC - * - * 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 com.google.cloud.mcp.e2e; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import com.google.cloud.mcp.McpToolboxClient; -import com.google.cloud.mcp.tool.Tool; -import com.google.cloud.mcp.tool.ToolDefinition; -import com.google.cloud.mcp.tool.ToolResult; -import java.util.Map; -import java.util.concurrent.CompletableFuture; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; - -class McpToolboxClientE2ETest { - - @RegisterExtension static ToolboxE2ESetup server = new ToolboxE2ESetup(); - - private McpToolboxClient client; - - @BeforeEach - void setUp() { - client = McpToolboxClient.builder().baseUrl(server.getBaseUrl()).build(); - } - - // --- TestBasicE2E --- - - @Test - void testLoadToolsetSpecific() { - Map tools1 = client.loadToolset("my-toolset").join(); - assertEquals(1, tools1.size()); - assertTrue(tools1.containsKey("get-row-by-id")); - - Map tools2 = client.loadToolset("my-toolset-2").join(); - assertEquals(2, tools2.size()); - assertTrue(tools2.containsKey("get-n-rows")); - assertTrue(tools2.containsKey("get-row-by-id")); - } - - @Test - void testLoadToolsetDefault() { - Map tools = client.loadToolset().join(); - assertEquals(7, tools.size()); - assertTrue(tools.containsKey("get-row-by-content-auth")); - assertTrue(tools.containsKey("get-row-by-email-auth")); - assertTrue(tools.containsKey("get-row-by-id-auth")); - assertTrue(tools.containsKey("get-row-by-id")); - assertTrue(tools.containsKey("get-n-rows")); - assertTrue(tools.containsKey("search-rows")); - assertTrue(tools.containsKey("process-data")); - } - - @Test - void testRunTool() { - Tool tool = client.loadTool("get-n-rows").join(); - ToolResult result = tool.execute(Map.of("num_rows", "2")).join(); - - if (result.isError()) { - System.out.println("ERROR OUTPUT: " + getTextContent(result)); - } - - assertFalse( - result.isError(), "Expected successful result, but got error: " + getTextContent(result)); - String output = getTextContent(result); - assertTrue(output.contains("row1"), "Output didn't contain row1. Actual output: " + output); - assertTrue(output.contains("row2")); - assertFalse(output.contains("row3")); - } - - // --- TestBindParams --- - - @Test - void testBindParams() { - Tool tool = client.loadTool("get-n-rows").join(); - Tool boundTool = tool.bindParam("num_rows", "3"); - - ToolResult result = boundTool.execute(Map.of()).join(); - String output = getTextContent(result); - - assertTrue(output.contains("row1"), "Actual output: " + output); - assertTrue(output.contains("row2")); - assertTrue(output.contains("row3")); - assertFalse(output.contains("row4")); - } - - @Test - void testBindParamsCallable() { - Tool tool = client.loadTool("get-n-rows").join(); - Tool boundTool = tool.bindParam("num_rows", () -> "3"); - - ToolResult result = boundTool.execute(Map.of()).join(); - String output = getTextContent(result); - - assertTrue(output.contains("row1"), "Actual output: " + output); - assertTrue(output.contains("row2")); - assertTrue(output.contains("row3")); - assertFalse(output.contains("row4")); - } - - // --- TestAuth --- - - @Test - void testRunToolAuth() { - Tool tool = - client - .loadTool("get-row-by-id-auth") - .join() - .addAuthTokenGetter( - "my-test-auth", () -> CompletableFuture.completedFuture(server.getAuthToken1())); - - ToolResult result = tool.execute(Map.of("id", "2")).join(); - assertFalse(result.isError()); - String output = getTextContent(result); - assertTrue(output.contains("row2")); - } - - @Test - void testRunToolWrongAuth() { - Tool tool = - client - .loadTool("get-row-by-id-auth") - .join() - .addAuthTokenGetter( - "my-test-auth", () -> CompletableFuture.completedFuture(server.getAuthToken2())); - - ToolResult result = tool.execute(Map.of("id", "2")).join(); - assertTrue( - result.isError(), - "Expected error for wrong auth. Actual output: " + getTextContent(result)); - assertTrue( - getTextContent(result).toLowerCase().contains("unauthorized"), - "Actual output: " + getTextContent(result)); - } - - @Test - void testRunToolParamAuth() { - Tool tool = - client - .loadTool("get-row-by-email-auth") - .join() - .addAuthTokenGetter( - "my-test-auth", () -> CompletableFuture.completedFuture(server.getAuthToken1())); - - ToolResult result = tool.execute(Map.of()).join(); - assertFalse(result.isError(), "Expected success but got error: " + getTextContent(result)); - String output = getTextContent(result); - assertTrue(output.contains("row4"), "Actual output: " + output); - assertTrue(output.contains("row5")); - assertTrue(output.contains("row6")); - } - - @Test - void testRunToolParamAuthNoField() { - Tool tool = - client - .loadTool("get-row-by-content-auth") - .join() - .addAuthTokenGetter( - "my-test-auth", () -> CompletableFuture.completedFuture(server.getAuthToken1())); - - ToolResult result = tool.execute(Map.of()).join(); - assertTrue(result.isError()); - assertTrue(getTextContent(result).contains("no field named row_data")); - } - - private String getTextContent(ToolResult result) { - if (result.content() == null) return ""; - return result.content().stream() - .filter(c -> "text".equals(c.type()) && c.text() != null) - .map(c -> c.text()) - .collect(java.util.stream.Collectors.joining("\n")); - } -} diff --git a/src/test/java/com/google/cloud/mcp/e2e/ToolboxE2ESetup.java b/src/test/java/com/google/cloud/mcp/e2e/ToolboxE2ESetup.java index 28a3757..adb3129 100644 --- a/src/test/java/com/google/cloud/mcp/e2e/ToolboxE2ESetup.java +++ b/src/test/java/com/google/cloud/mcp/e2e/ToolboxE2ESetup.java @@ -39,6 +39,9 @@ public class ToolboxE2ESetup implements BeforeAllCallback, AfterAllCallback { private static final String PROJECT_ID_ENV = "GOOGLE_CLOUD_PROJECT"; private static final String TOOLBOX_VERSION_ENV = "TOOLBOX_VERSION"; private static final String TOOLBOX_MANIFEST_VERSION_ENV = "TOOLBOX_MANIFEST_VERSION"; + private static final String TOOLBOX_SERVER_URL_ENV = "TOOLBOX_SERVER_URL"; + private static final String TOOLBOX_AUTH_TOKEN_1_ENV = "TOOLBOX_AUTH_TOKEN_1"; + private static final String TOOLBOX_AUTH_TOKEN_2_ENV = "TOOLBOX_AUTH_TOKEN_2"; private static final String BINARY_NAME = "toolbox"; private Process serverProcess; @@ -48,10 +51,36 @@ public class ToolboxE2ESetup implements BeforeAllCallback, AfterAllCallback { @Override public void beforeAll(ExtensionContext context) throws Exception { + String serverUrl = System.getenv(TOOLBOX_SERVER_URL_ENV); String projectId = System.getenv(PROJECT_ID_ENV); + org.junit.jupiter.api.Assumptions.assumeTrue( - projectId != null && !projectId.trim().isEmpty(), - "Skipping E2E tests because " + PROJECT_ID_ENV + " is not set."); + (projectId != null && !projectId.trim().isEmpty()) + || (serverUrl != null && !serverUrl.trim().isEmpty()), + "Skipping E2E tests because neither " + + PROJECT_ID_ENV + + " nor " + + TOOLBOX_SERVER_URL_ENV + + " is set."); + + // If an external server URL is provided, use it directly + if (serverUrl != null && !serverUrl.trim().isEmpty()) { + logger.warning("================================================================"); + logger.warning("WARNING: Using external pre-configured TOOLBOX_SERVER_URL: " + serverUrl); + logger.warning("Ensure external server was started with manifest revision 34+"); + logger.warning("================================================================"); + authToken1 = System.getenv(TOOLBOX_AUTH_TOKEN_1_ENV); + authToken2 = System.getenv(TOOLBOX_AUTH_TOKEN_2_ENV); + if (authToken1 == null && projectId != null && !projectId.trim().isEmpty()) { + String client1Id = accessSecretVersion(projectId, "sdk_testing_client1", "latest"); + authToken1 = getAuthToken(client1Id); + } + if (authToken2 == null && projectId != null && !projectId.trim().isEmpty()) { + String client2Id = accessSecretVersion(projectId, "sdk_testing_client2", "latest"); + authToken2 = getAuthToken(client2Id); + } + return; + } String toolboxVersion = getEnvVar(TOOLBOX_VERSION_ENV); String manifestVersion = getEnvVar(TOOLBOX_MANIFEST_VERSION_ENV); @@ -107,9 +136,10 @@ public void afterAll(ExtensionContext context) { serverProcess.destroy(); try { if (!serverProcess.waitFor(5, TimeUnit.SECONDS)) { - serverProcess.destroy(); + serverProcess.destroyForcibly(); } } catch (InterruptedException e) { + serverProcess.destroyForcibly(); Thread.currentThread().interrupt(); } } @@ -124,9 +154,20 @@ public void afterAll(ExtensionContext context) { } public String getBaseUrl() { + String envUrl = System.getenv(TOOLBOX_SERVER_URL_ENV); + if (envUrl != null && !envUrl.trim().isEmpty()) { + return envUrl; + } return "http://localhost:5000/mcp"; } + public static String getTextContent(com.google.cloud.mcp.tool.ToolResult result) { + if (result == null) { + return ""; + } + return result.text(); + } + private void startServer() throws IOException, InterruptedException { logger.info("Starting Toolbox Server..."); ProcessBuilder pb =