From 169985cd60ea70fc26a697a4c0b7bb298824a008 Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Thu, 10 Sep 2026 20:07:01 +0530 Subject: [PATCH 1/2] feat(demo): update cymbal-transit to Java SDK 1.0.0 and add comprehensive tests - Bump mcp-toolbox-sdk-java dependency in cymbal-transit to 1.0.0 with release-please annotation - Add demo-applications/cymbal-transit files to release-please-config.json - Map root GET / in CymbalTransitController to serve Thymeleaf index.html - Ensure resilient ADC credential handling during local execution - Fix potential IndexOutOfBoundsException in bookTicket when tool response content is empty - Add unit test suite covering McpToolboxService, TransitAgentTools, and TransitAgentController - Update demo documentation and deployment instructions --- README.md | 2 +- demo-applications/cymbal-transit/README.md | 4 +- demo-applications/cymbal-transit/pom.xml | 3 +- .../cymbal/web/CymbalTransitController.java | 126 +++++++--- .../cymbal/web/McpToolboxServiceTest.java | 231 ++++++++++++++++++ .../web/TransitAgentControllerTest.java | 69 ++++++ .../cymbal/web/TransitAgentToolsTest.java | 84 +++++++ release-please-config.json | 4 +- 8 files changed, 485 insertions(+), 38 deletions(-) create mode 100644 demo-applications/cymbal-transit/src/test/java/cloudcode/cymbal/web/McpToolboxServiceTest.java create mode 100644 demo-applications/cymbal-transit/src/test/java/cloudcode/cymbal/web/TransitAgentControllerTest.java create mode 100644 demo-applications/cymbal-transit/src/test/java/cloudcode/cymbal/web/TransitAgentToolsTest.java diff --git a/README.md b/README.md index c8d933e..f3e92db 100644 --- a/README.md +++ b/README.md @@ -106,7 +106,7 @@ Add the dependency to your `pom.xml`: ``` dependencies { // Source: https://mvnrepository.com/artifact/com.google.cloud.mcp/mcp-toolbox-sdk-java - implementation("com.google.cloud.mcp:mcp-toolbox-sdk-java:0.2.0") + implementation("com.google.cloud.mcp:mcp-toolbox-sdk-java:1.0.0") // {x-version-update:mcp-toolbox-sdk-java:current} } ``` diff --git a/demo-applications/cymbal-transit/README.md b/demo-applications/cymbal-transit/README.md index 11abe35..cd52583 100644 --- a/demo-applications/cymbal-transit/README.md +++ b/demo-applications/cymbal-transit/README.md @@ -109,7 +109,7 @@ mvn clean install -U and ``` bash -mvn sprint-boot:run +mvn spring-boot:run ``` or @@ -117,6 +117,6 @@ or To directly deploy your agent to Cloud Run and test there: ``` bash -gcloud run deploy cymbal-transit --source . --set-env-vars GCP_PROJECT_ID=<>,GCP_REGION=us-central1,GEMINI_MODEL_NAME=gemini-2.5-flash,MCP_TOOLBOX_URL=<> --allow-unauthenticated +gcloud run deploy cymbal-transit --source . --set-env-vars GCP_PROJECT_ID=<>,GCP_REGION=us-central1,GEMINI_MODEL_NAME=gemini-3.8-flash,MCP_TOOLBOX_URL=<> --allow-unauthenticated ``` Replace the placeholder variables enclosed within <<>>. Ensure that your `MCP_TOOLBOX_URL` explicitly ends with `/mcp` (e.g., `https://my-toolbox-service.a.run.app/mcp`). diff --git a/demo-applications/cymbal-transit/pom.xml b/demo-applications/cymbal-transit/pom.xml index c72a0b5..e1103a9 100644 --- a/demo-applications/cymbal-transit/pom.xml +++ b/demo-applications/cymbal-transit/pom.xml @@ -69,7 +69,7 @@ com.google.cloud.mcp mcp-toolbox-sdk-java - 0.3.0-SNAPSHOT + 1.0.0 @@ -168,6 +168,7 @@ false + **/*Test.java **/*Tests.java diff --git a/demo-applications/cymbal-transit/src/main/java/cloudcode/cymbal/web/CymbalTransitController.java b/demo-applications/cymbal-transit/src/main/java/cloudcode/cymbal/web/CymbalTransitController.java index db75065..b51cb0f 100644 --- a/demo-applications/cymbal-transit/src/main/java/cloudcode/cymbal/web/CymbalTransitController.java +++ b/demo-applications/cymbal-transit/src/main/java/cloudcode/cymbal/web/CymbalTransitController.java @@ -30,31 +30,38 @@ import dev.langchain4j.service.SystemMessage; import dev.langchain4j.service.UserMessage; import java.util.Collections; +import java.util.HashMap; import java.util.Map; +import java.util.Objects; import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; import javax.annotation.PostConstruct; import javax.servlet.http.HttpSession; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; import org.springframework.stereotype.Service; import org.springframework.web.bind.annotation.*; -@SpringBootApplication +@Controller public class CymbalTransitController { + + @GetMapping("/") + public String index() { + return "index"; + } + public static void main(String[] args) { SpringApplication.run(CymbalTransitApplication.class, args); } } -/** - * 1. AI AGENT CONFIGURATION Configures Gemini 3.5 Flash and binds it to our LangChain4j Agent - * Interface. - */ +/** 1. AI AGENT CONFIGURATION Configures Gemini and binds it to our LangChain4j Agent Interface. */ @Configuration class AgentConfiguration { @@ -96,14 +103,14 @@ interface TransitAgent { + " ", "If you have to list the route details to the user, show it along with the full UUID and with" + " other details that are meaningful. If the user chooses to book ticket as the next step," - + " prompt them to copy the correct UUID nad paste so the transaction can be confirmed.", + + " prompt them to copy the correct UUID and paste so the transaction can be confirmed.", "ONLY if the user asks a specifically narrowed-down question, asks for precise times, or" + " assigns a booking task, or asks about policies should you route to the specific tools" + " like 'querySchedules', 'bookTicket', 'searchPolicies'.", "Remember the tool 'querySchedules' is for finding schedules between cities, 'bookTicket' is" + " for booking ticket actionable between 2 cities, 'searchPolicies' is for finding" + " matching policies for this company.", - "Be intuitive and intelligent in finding the context even when user has typos. Do no" + "Be intuitive and intelligent in finding the context even when user has typos. Do not" + " hallucinate and make up stuff though. Use only data from the tools. ", "Don't show any asterisks while listing results. Keep it formatted and numbered or bulleted." + " asterisks distract." @@ -156,42 +163,84 @@ public String searchPolicies(String searchQuery) { @Service class McpToolboxService { + private static final Logger logger = LoggerFactory.getLogger(McpToolboxService.class); + private McpToolboxClient mcpClient; private String idToken; @Value("${MCP_TOOLBOX_URL:fallback_toolbox_url}") private String targetUrl; + public McpToolboxService() {} + + McpToolboxService(McpToolboxClient mcpClient, String idToken) { + this.mcpClient = mcpClient; + this.idToken = idToken; + } + + void setMcpClient(McpToolboxClient mcpClient) { + this.mcpClient = mcpClient; + } + + void setIdToken(String idToken) { + this.idToken = idToken; + } + + void setTargetUrl(String targetUrl) { + this.targetUrl = targetUrl; + } + @PostConstruct public void init() { try { String tokenAudience = targetUrl; - System.out.println("--- Initializing MCP Toolbox Client ---"); + logger.info("--- Initializing MCP Toolbox Client for target: {} ---", targetUrl); - GoogleCredentials credentials = GoogleCredentials.getApplicationDefault(); - if (!(credentials instanceof IdTokenProvider)) { - throw new RuntimeException("Loaded credentials do not support ID Tokens."); + GoogleCredentials credentials = null; + try { + credentials = GoogleCredentials.getApplicationDefault(); + } catch (Exception e) { + logger.warn("Could not load Google Application Default Credentials: {}", e.getMessage()); } - this.idToken = - ((IdTokenProvider) credentials) - .idTokenWithAudience(tokenAudience, Collections.emptyList()) - .getTokenValue(); + if (credentials instanceof IdTokenProvider) { + try { + this.idToken = + ((IdTokenProvider) credentials) + .idTokenWithAudience(tokenAudience, Collections.emptyList()) + .getTokenValue(); + logger.info("Successfully acquired Google Cloud ID token."); + } catch (Exception e) { + logger.warn( + "Failed to obtain ID token with audience {}: {}", tokenAudience, e.getMessage()); + } + } else { + logger.info( + "Credentials do not implement IdTokenProvider (e.g., local UserCredentials). Proceeding" + + " without ID token."); + } - this.mcpClient = McpToolboxClient.builder().baseUrl(targetUrl).apiKey(idToken).build(); + var clientBuilder = McpToolboxClient.builder().baseUrl(targetUrl); + if (this.idToken != null && !this.idToken.isBlank()) { + clientBuilder.apiKey(this.idToken); + } + this.mcpClient = clientBuilder.build(); mcpClient .listTools() .thenAccept( tools -> { - System.out.println("Successfully discovered " + tools.size() + " tools."); + logger.info("Successfully discovered {} tools.", tools.size()); }) - .join(); + .exceptionally( + ex -> { + logger.warn("Unable to list tools during startup: {}", ex.getMessage()); + return null; + }); } catch (Exception e) { - System.err.println("Failed to initialize MCP Toolbox Client:"); - e.printStackTrace(); + logger.error("Failed to initialize MCP Toolbox Client:", e); } } @@ -200,33 +249,35 @@ public CompletableFuture findAllSchedules() { .invokeTool("find-bus-schedules", Collections.emptyMap()) .thenApply( result -> { - if (result.isError() || result.content() == null || result.content().isEmpty()) + if (result.isError() || result.content() == null || result.content().isEmpty()) { return "No schedules found."; + } return result.content().stream() - .map(content -> content.text()) + .map(content -> content != null ? Objects.toString(content.text(), "") : "") .collect(Collectors.joining(", ", "[", "]")); }); } public CompletableFuture querySchedules(String origin, String destination) { - java.util.Map params = new java.util.HashMap<>(); + Map params = new HashMap<>(); params.put("origin", origin); params.put("destination", destination); return mcpClient .invokeTool("query-schedules", params) .thenApply( result -> { - if (result.isError() || result.content() == null || result.content().isEmpty()) + if (result.isError() || result.content() == null || result.content().isEmpty()) { return "No specific schedules found."; - System.out.println(result); + } return result.content().stream() - .map(content -> content.text()) + .map(content -> content != null ? Objects.toString(content.text(), "") : "") .collect(Collectors.joining(", ", "[", "]")); }); } public CompletableFuture bookTicket(String tripId, String passengerName) { - AuthTokenGetter toolAuthGetter = () -> CompletableFuture.completedFuture(idToken); + AuthTokenGetter toolAuthGetter = + () -> CompletableFuture.completedFuture(idToken != null ? idToken : ""); return mcpClient .loadTool("book-ticket", Collections.singletonMap("google_auth", toolAuthGetter)) .thenCompose( @@ -236,7 +287,13 @@ public CompletableFuture bookTicket(String tripId, String passengerName) .thenApply( result -> { if (result.isError() || result.content() == null || result.content().isEmpty()) { - System.err.println("Tool execution failed: " + result.content().get(0).text()); + String errorMsg = + (result != null && result.content() != null && !result.content().isEmpty()) + ? result.content().get(0).text() + : (result != null && result.isError() + ? "Error returned by tool" + : "Empty tool result"); + logger.error("Tool execution failed: {}", errorMsg); return "Transaction failed."; } return result.content().get(0).text(); @@ -244,14 +301,17 @@ public CompletableFuture bookTicket(String tripId, String passengerName) } public CompletableFuture searchPolicies(String searchQuery) { + Map params = new HashMap<>(); + params.put("search_query", searchQuery); return mcpClient - .invokeTool("search-policies", Map.of("search_query", searchQuery)) + .invokeTool("search-policies", params) .thenApply( result -> { - if (result.isError() || result.content() == null || result.content().isEmpty()) + if (result.isError() || result.content() == null || result.content().isEmpty()) { return "No policy information found."; + } return result.content().stream() - .map(content -> content.text()) + .map(content -> content != null ? Objects.toString(content.text(), "") : "") .collect(Collectors.joining(", ", "[", "]")); }); } @@ -278,7 +338,7 @@ public ResponseEntity handleUserChat( // We use the HTTP Session ID to tell LangChain4j which memory context to load String sessionId = session.getId(); - // Let Gemini 3.5 Flash handle the thinking, tool execution, and response generation! + // Let Gemini handle the thinking, tool execution, and response generation! String agentResponse = transitAgent.chat(sessionId, userMessage); return ResponseEntity.ok(agentResponse); diff --git a/demo-applications/cymbal-transit/src/test/java/cloudcode/cymbal/web/McpToolboxServiceTest.java b/demo-applications/cymbal-transit/src/test/java/cloudcode/cymbal/web/McpToolboxServiceTest.java new file mode 100644 index 0000000..4ac51cc --- /dev/null +++ b/demo-applications/cymbal-transit/src/test/java/cloudcode/cymbal/web/McpToolboxServiceTest.java @@ -0,0 +1,231 @@ +/* + * 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 cloudcode.cymbal.web; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.cloud.mcp.McpToolboxClient; +import com.google.cloud.mcp.tool.Tool; +import com.google.cloud.mcp.tool.ToolResult; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +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.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@Timeout(value = 10, unit = TimeUnit.SECONDS) +public class McpToolboxServiceTest { + + @Mock private McpToolboxClient mockClient; + @Mock private Tool mockTool; + @Mock private Tool mockBoundTool; + + private McpToolboxService service; + + @BeforeEach + void setUp() { + service = new McpToolboxService(mockClient, "test-token"); + service.setTargetUrl("https://test-server.run.app/mcp"); + } + + @Test + void testFindAllSchedulesSuccess() { + ToolResult.Content item1 = new ToolResult.Content("text", "Route A - 08:00"); + ToolResult.Content item2 = new ToolResult.Content("text", "Route B - 12:00"); + ToolResult result = new ToolResult(List.of(item1, item2), false); + + when(mockClient.invokeTool(eq("find-bus-schedules"), anyMap())) + .thenReturn(CompletableFuture.completedFuture(result)); + + String schedules = service.findAllSchedules().join(); + assertEquals("[Route A - 08:00, Route B - 12:00]", schedules); + verify(mockClient).invokeTool(eq("find-bus-schedules"), eq(Collections.emptyMap())); + } + + @Test + void testFindAllSchedulesError() { + ToolResult result = new ToolResult(Collections.emptyList(), true); + when(mockClient.invokeTool(eq("find-bus-schedules"), anyMap())) + .thenReturn(CompletableFuture.completedFuture(result)); + + String schedules = service.findAllSchedules().join(); + assertEquals("No schedules found.", schedules); + } + + @Test + void testFindAllSchedulesEmptyContent() { + ToolResult result = new ToolResult(Collections.emptyList(), false); + when(mockClient.invokeTool(eq("find-bus-schedules"), anyMap())) + .thenReturn(CompletableFuture.completedFuture(result)); + + String schedules = service.findAllSchedules().join(); + assertEquals("No schedules found.", schedules); + } + + @Test + void testFindAllSchedulesNullContent() { + ToolResult result = new ToolResult(null, false); + when(mockClient.invokeTool(eq("find-bus-schedules"), anyMap())) + .thenReturn(CompletableFuture.completedFuture(result)); + + String schedules = service.findAllSchedules().join(); + assertEquals("No schedules found.", schedules); + } + + @Test + void testQuerySchedulesSuccess() { + ToolResult.Content content = + new ToolResult.Content("text", "Trip 123: New York to Boston at 09:00"); + ToolResult result = new ToolResult(List.of(content), false); + + Map expectedParams = new LinkedHashMap<>(); + expectedParams.put("origin", "New York"); + expectedParams.put("destination", "Boston"); + + when(mockClient.invokeTool(eq("query-schedules"), anyMap())) + .thenReturn(CompletableFuture.completedFuture(result)); + + String schedules = service.querySchedules("New York", "Boston").join(); + assertEquals("[Trip 123: New York to Boston at 09:00]", schedules); + verify(mockClient).invokeTool(eq("query-schedules"), eq(expectedParams)); + } + + @Test + void testQuerySchedulesNotFound() { + ToolResult result = new ToolResult(Collections.emptyList(), false); + when(mockClient.invokeTool(eq("query-schedules"), anyMap())) + .thenReturn(CompletableFuture.completedFuture(result)); + + String schedules = service.querySchedules("Seattle", "Miami").join(); + assertEquals("No specific schedules found.", schedules); + } + + @Test + void testQuerySchedulesError() { + ToolResult result = new ToolResult(Collections.emptyList(), true); + when(mockClient.invokeTool(eq("query-schedules"), anyMap())) + .thenReturn(CompletableFuture.completedFuture(result)); + + String schedules = service.querySchedules("Seattle", "Miami").join(); + assertEquals("No specific schedules found.", schedules); + } + + @Test + void testSearchPoliciesSuccess() { + ToolResult.Content content = + new ToolResult.Content("text", "Pets under 25 lbs allowed in carrier."); + ToolResult result = new ToolResult(List.of(content), false); + + Map expectedParams = new LinkedHashMap<>(); + expectedParams.put("search_query", "Can I bring my pet?"); + + when(mockClient.invokeTool(eq("search-policies"), anyMap())) + .thenReturn(CompletableFuture.completedFuture(result)); + + String policy = service.searchPolicies("Can I bring my pet?").join(); + assertEquals("[Pets under 25 lbs allowed in carrier.]", policy); + verify(mockClient).invokeTool(eq("search-policies"), eq(expectedParams)); + } + + @Test + void testSearchPoliciesNotFound() { + ToolResult result = new ToolResult(Collections.emptyList(), false); + when(mockClient.invokeTool(eq("search-policies"), anyMap())) + .thenReturn(CompletableFuture.completedFuture(result)); + + String policy = service.searchPolicies("spaceships").join(); + assertEquals("No policy information found.", policy); + } + + @Test + void testSearchPoliciesError() { + ToolResult result = new ToolResult(Collections.emptyList(), true); + when(mockClient.invokeTool(eq("search-policies"), anyMap())) + .thenReturn(CompletableFuture.completedFuture(result)); + + String policy = service.searchPolicies("pets").join(); + assertEquals("No policy information found.", policy); + } + + @Test + void testBookTicketSuccess() { + ToolResult.Content content = + new ToolResult.Content("text", "Booking confirmed: ID-9988 for Jane Doe"); + ToolResult result = new ToolResult(List.of(content), false); + + when(mockClient.loadTool(eq("book-ticket"), anyMap())) + .thenReturn(CompletableFuture.completedFuture(mockTool)); + when(mockTool.bindParam("passenger_name", "Jane Doe")).thenReturn(mockBoundTool); + when(mockBoundTool.execute(eq(Collections.singletonMap("trip_id", "trip-456")))) + .thenReturn(CompletableFuture.completedFuture(result)); + + String response = service.bookTicket("trip-456", "Jane Doe").join(); + assertEquals("Booking confirmed: ID-9988 for Jane Doe", response); + + verify(mockClient).loadTool(eq("book-ticket"), anyMap()); + verify(mockTool).bindParam("passenger_name", "Jane Doe"); + verify(mockBoundTool).execute(Collections.singletonMap("trip_id", "trip-456")); + } + + @Test + void testBookTicketFailureContentEmpty() { + // Verifies that empty content does not throw IndexOutOfBoundsException + ToolResult result = new ToolResult(Collections.emptyList(), true); + + when(mockClient.loadTool(eq("book-ticket"), anyMap())) + .thenReturn(CompletableFuture.completedFuture(mockTool)); + when(mockTool.bindParam("passenger_name", "Jane Doe")).thenReturn(mockBoundTool); + when(mockBoundTool.execute(anyMap())).thenReturn(CompletableFuture.completedFuture(result)); + + String response = service.bookTicket("trip-456", "Jane Doe").join(); + assertEquals("Transaction failed.", response); + } + + @Test + void testBookTicketFailureContentNull() { + ToolResult result = new ToolResult(null, true); + + when(mockClient.loadTool(eq("book-ticket"), anyMap())) + .thenReturn(CompletableFuture.completedFuture(mockTool)); + when(mockTool.bindParam("passenger_name", "Jane Doe")).thenReturn(mockBoundTool); + when(mockBoundTool.execute(anyMap())).thenReturn(CompletableFuture.completedFuture(result)); + + String response = service.bookTicket("trip-456", "Jane Doe").join(); + assertEquals("Transaction failed.", response); + } + + @Test + void testInitHandlesExceptionGracefully() { + McpToolboxService uninitialized = new McpToolboxService(); + uninitialized.setTargetUrl("https://invalid-host-for-testing.example.com/mcp"); + // init() should catch credentials/discovery exceptions and not crash + assertDoesNotThrow(() -> uninitialized.init()); + } +} diff --git a/demo-applications/cymbal-transit/src/test/java/cloudcode/cymbal/web/TransitAgentControllerTest.java b/demo-applications/cymbal-transit/src/test/java/cloudcode/cymbal/web/TransitAgentControllerTest.java new file mode 100644 index 0000000..6983faf --- /dev/null +++ b/demo-applications/cymbal-transit/src/test/java/cloudcode/cymbal/web/TransitAgentControllerTest.java @@ -0,0 +1,69 @@ +/* + * 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 cloudcode.cymbal.web; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockHttpSession; + +@ExtendWith(MockitoExtension.class) +@Timeout(value = 10, unit = TimeUnit.SECONDS) +public class TransitAgentControllerTest { + + @Mock private TransitAgent mockAgent; + + private TransitAgentController agentController; + private CymbalTransitController cymbalController; + + @BeforeEach + void setUp() { + agentController = new TransitAgentController(mockAgent); + cymbalController = new CymbalTransitController(); + } + + @Test + void testIndexEndpoint() { + assertEquals("index", cymbalController.index()); + } + + @Test + void testHandleUserChatSuccess() { + MockHttpSession session = new MockHttpSession(); + String sessionId = session.getId(); + String userMessage = "Find buses to Boston"; + String expectedResponse = "Here are the schedules: 09:00 AM, 12:00 PM."; + + when(mockAgent.chat(sessionId, userMessage)).thenReturn(expectedResponse); + + ResponseEntity response = agentController.handleUserChat(userMessage, session); + + assertEquals(HttpStatus.OK, response.getStatusCode()); + assertEquals(expectedResponse, response.getBody()); + verify(mockAgent).chat(sessionId, userMessage); + } +} diff --git a/demo-applications/cymbal-transit/src/test/java/cloudcode/cymbal/web/TransitAgentToolsTest.java b/demo-applications/cymbal-transit/src/test/java/cloudcode/cymbal/web/TransitAgentToolsTest.java new file mode 100644 index 0000000..73ff8e6 --- /dev/null +++ b/demo-applications/cymbal-transit/src/test/java/cloudcode/cymbal/web/TransitAgentToolsTest.java @@ -0,0 +1,84 @@ +/* + * 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 cloudcode.cymbal.web; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.concurrent.CompletableFuture; +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.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +@Timeout(value = 10, unit = TimeUnit.SECONDS) +public class TransitAgentToolsTest { + + @Mock private McpToolboxService mockService; + + private TransitAgentTools tools; + + @BeforeEach + void setUp() { + tools = new TransitAgentTools(mockService); + } + + @Test + void testFindAllSchedules() { + when(mockService.findAllSchedules()) + .thenReturn(CompletableFuture.completedFuture("[Route A, Route B]")); + + String result = tools.findAllSchedules(); + assertEquals("[Route A, Route B]", result); + verify(mockService).findAllSchedules(); + } + + @Test + void testQuerySchedules() { + when(mockService.querySchedules("New York", "Boston")) + .thenReturn(CompletableFuture.completedFuture("[Trip 123]")); + + String result = tools.querySchedules("New York", "Boston"); + assertEquals("[Trip 123]", result); + verify(mockService).querySchedules("New York", "Boston"); + } + + @Test + void testBookTicket() { + when(mockService.bookTicket("trip-456", "Jane Doe")) + .thenReturn(CompletableFuture.completedFuture("Booking confirmed")); + + String result = tools.bookTicket("trip-456", "Jane Doe"); + assertEquals("Booking confirmed", result); + verify(mockService).bookTicket("trip-456", "Jane Doe"); + } + + @Test + void testSearchPolicies() { + when(mockService.searchPolicies("pets")) + .thenReturn(CompletableFuture.completedFuture("[Pets policy]")); + + String result = tools.searchPolicies("pets"); + assertEquals("[Pets policy]", result); + verify(mockService).searchPolicies("pets"); + } +} diff --git a/release-please-config.json b/release-please-config.json index 8de9a16..c7f2182 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -30,7 +30,9 @@ "extra-files": [ "README.md", "example/README.md", - "example/pom.xml" + "example/pom.xml", + "demo-applications/cymbal-transit/README.md", + "demo-applications/cymbal-transit/pom.xml" ] } }, From 3ce064c8807d59ad58bc768ea8e3da49e2fc6c4a Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Fri, 11 Sep 2026 21:00:16 +0530 Subject: [PATCH 2/2] refactor(demo): remove redundant main method from CymbalTransitController --- .../java/cloudcode/cymbal/web/CymbalTransitController.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/demo-applications/cymbal-transit/src/main/java/cloudcode/cymbal/web/CymbalTransitController.java b/demo-applications/cymbal-transit/src/main/java/cloudcode/cymbal/web/CymbalTransitController.java index b51cb0f..43e2e34 100644 --- a/demo-applications/cymbal-transit/src/main/java/cloudcode/cymbal/web/CymbalTransitController.java +++ b/demo-applications/cymbal-transit/src/main/java/cloudcode/cymbal/web/CymbalTransitController.java @@ -16,7 +16,6 @@ package cloudcode.cymbal.web; -import cloudcode.cymbal.CymbalTransitApplication; import com.google.auth.oauth2.GoogleCredentials; import com.google.auth.oauth2.IdTokenProvider; import com.google.cloud.mcp.McpToolboxClient; @@ -40,7 +39,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.SpringApplication; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.ResponseEntity; @@ -55,10 +53,6 @@ public class CymbalTransitController { public String index() { return "index"; } - - public static void main(String[] args) { - SpringApplication.run(CymbalTransitApplication.class, args); - } } /** 1. AI AGENT CONFIGURATION Configures Gemini and binds it to our LangChain4j Agent Interface. */