diff --git a/src/test/java/com/google/cloud/mcp/e2e/McpToolboxClientTest.java b/src/test/java/com/google/cloud/mcp/e2e/McpToolboxClientE2ETest.java similarity index 53% rename from src/test/java/com/google/cloud/mcp/e2e/McpToolboxClientTest.java rename to src/test/java/com/google/cloud/mcp/e2e/McpToolboxClientE2ETest.java index 883557b..ab244e3 100644 --- a/src/test/java/com/google/cloud/mcp/e2e/McpToolboxClientTest.java +++ b/src/test/java/com/google/cloud/mcp/e2e/McpToolboxClientE2ETest.java @@ -16,8 +16,11 @@ package com.google.cloud.mcp.e2e; +import static com.google.cloud.mcp.e2e.ToolboxE2ESetup.getTextContent; 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.google.cloud.mcp.McpToolboxClient; @@ -26,10 +29,14 @@ import com.google.cloud.mcp.tool.ToolResult; 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; +@Timeout(value = 60, unit = TimeUnit.SECONDS) class McpToolboxClientE2ETest { @RegisterExtension static ToolboxE2ESetup server = new ToolboxE2ESetup(); @@ -41,7 +48,7 @@ void setUp() { client = McpToolboxClient.builder().baseUrl(server.getBaseUrl()).build(); } - // --- TestBasicE2E --- + // --- Toolset Loading & Error Tests --- @Test void testLoadToolsetSpecific() { @@ -68,15 +75,43 @@ void testLoadToolsetDefault() { 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(); - 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); @@ -85,7 +120,43 @@ void testRunTool() { assertFalse(output.contains("row3")); } - // --- TestBindParams --- + @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() { @@ -115,7 +186,30 @@ void testBindParamsCallable() { assertFalse(output.contains("row4")); } - // --- TestAuth --- + @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() { @@ -150,6 +244,20 @@ void testRunToolWrongAuth() { "Actual output: " + getTextContent(result)); } + @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: " + getTextContent(result)); + assertTrue( + getTextContent(result).toLowerCase().contains("unauthorized") + || getTextContent(result).contains("401"), + "Expected unauthorized/401 error message. Actual output: " + getTextContent(result)); + } + @Test void testRunToolParamAuth() { Tool tool = @@ -181,11 +289,25 @@ void testRunToolParamAuthNoField() { 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")); + @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()); } } diff --git a/src/test/java/com/google/cloud/mcp/e2e/McpToolboxComplexTypesE2ETest.java b/src/test/java/com/google/cloud/mcp/e2e/McpToolboxComplexTypesE2ETest.java new file mode 100644 index 0000000..f654596 --- /dev/null +++ b/src/test/java/com/google/cloud/mcp/e2e/McpToolboxComplexTypesE2ETest.java @@ -0,0 +1,257 @@ +/* + * 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 com.google.cloud.mcp.e2e.ToolboxE2ESetup.getTextContent; +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.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.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +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; + +@Timeout(value = 60, unit = TimeUnit.SECONDS) +class McpToolboxComplexTypesE2ETest { + + @RegisterExtension static ToolboxE2ESetup server = new ToolboxE2ESetup(); + + private McpToolboxClient client; + + @BeforeEach + void setUp() { + client = McpToolboxClient.builder().baseUrl(server.getBaseUrl()).build(); + } + + // --- Optional 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: " + getTextContent(result)); + String output = getTextContent(result); + 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: " + getTextContent(result)); + String output = getTextContent(result); + assertTrue(output.contains("twishabansal@google.com")); + assertTrue(output.contains("row3")); + assertFalse(output.contains("row2")); + } + + @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: " + getTextContent(result)); + String output = getTextContent(result).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")); + } + + // --- 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() { + 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: " + getTextContent(result)); + String output = getTextContent(result); + assertTrue( + output.contains("\"execution_context\":{\"env\":\"prod\",\"id\":1234,\"user\":1234.5}"), + "Output did not contain expected execution_context: " + output); + assertTrue( + output.contains("\"user_scores\":{\"user1\":100,\"user2\":200}"), + "Output did not contain expected user_scores: " + output); + assertTrue( + output.contains("\"feature_flags\":{\"new_feature\":true}"), + "Output did not contain expected feature_flags: " + output); + } + + @Test + void testProcessDataOmittingOptionalMap() { + 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: " + getTextContent(result)); + String output = getTextContent(result); + assertTrue( + output.contains("\"execution_context\":{\"env\":\"dev\"}"), + "Output did not contain expected execution_context: " + output); + assertTrue( + output.contains("\"user_scores\":{\"user3\":300}"), + "Output did not contain expected user_scores: " + output); + assertTrue( + output.contains("\"feature_flags\":null"), + "Output did not contain expected null feature_flags: " + output); + } +} diff --git a/src/test/java/com/google/cloud/mcp/e2e/McpToolboxProtocolE2ETest.java b/src/test/java/com/google/cloud/mcp/e2e/McpToolboxProtocolE2ETest.java new file mode 100644 index 0000000..13d09c1 --- /dev/null +++ b/src/test/java/com/google/cloud/mcp/e2e/McpToolboxProtocolE2ETest.java @@ -0,0 +1,78 @@ +/* + * 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 com.google.cloud.mcp.e2e.ToolboxE2ESetup.getTextContent; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +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.ToolResult; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.junit.jupiter.api.extension.RegisterExtension; + +@Timeout(value = 60, unit = TimeUnit.SECONDS) +class McpToolboxProtocolE2ETest { + + @RegisterExtension static ToolboxE2ESetup server = new ToolboxE2ESetup(); + + @Test + void testClientWithCustomHeaders() { + McpToolboxClient client = + McpToolboxClient.builder() + .baseUrl(server.getBaseUrl()) + .headers(Map.of("X-Integration-Test-Suite", "Java-Parity-E2E")) + .build(); + + Tool tool = client.loadTool("get-n-rows").join(); + assertNotNull(tool); + ToolResult result = tool.execute(Map.of("num_rows", "2")).join(); + assertFalse(result.isError()); + String output = getTextContent(result); + assertTrue(output.contains("row1")); + assertTrue(output.contains("row2")); + } + + @Test + void testClientWithExplicitProtocolVersions() { + ProtocolVersion[] versions = + new ProtocolVersion[] { + ProtocolVersion.VERSION_2024_11_05, + ProtocolVersion.VERSION_2025_03_26, + ProtocolVersion.VERSION_2025_06_18, + ProtocolVersion.VERSION_2025_11_25 + }; + + for (ProtocolVersion version : versions) { + McpToolboxClient client = + McpToolboxClient.builder().baseUrl(server.getBaseUrl()).protocolVersion(version).build(); + + Tool tool = client.loadTool("get-n-rows").join(); + assertNotNull(tool, "Failed to load tool with protocol " + version); + ToolResult result = tool.execute(Map.of("num_rows", "1")).join(); + assertFalse(result.isError(), "Execution failed for protocol " + version); + String output = getTextContent(result); + assertTrue(output.contains("row1"), "Expected row1 for protocol " + version); + } + } +} 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..b2bea2a 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,33 @@ 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.info("Using pre-configured TOOLBOX_SERVER_URL: " + serverUrl); + 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); @@ -124,9 +150,23 @@ 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 || result.content() == null) { + return ""; + } + return result.content().stream() + .filter(c -> "text".equals(c.type()) && c.text() != null) + .map(com.google.cloud.mcp.tool.ToolResult.Content::text) + .collect(java.util.stream.Collectors.joining("\n")); + } + private void startServer() throws IOException, InterruptedException { logger.info("Starting Toolbox Server..."); ProcessBuilder pb =