From 714a0cf080f095d72a74ecebb59161e756cfc6ce Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Thu, 25 Jun 2026 16:52:14 +0530 Subject: [PATCH 1/3] refactor: Restructure SDK into subpackages and implement versioned HTTP transport layers --- demo-applications/cymbal-transit/pom.xml | 2 +- .../cymbal/CymbalTransitApplication.java | 4 +- .../cymbal/web/CymbalTransitController.java | 410 +++++++++--------- example/pom.xml | 2 +- .../cloudcode/helloworld/ExampleUsage.java | 285 ++++++------ .../helloworld/InputValidationTest.java | 151 +++---- .../cloudcode/helloworld/StrictFlagTest.java | 13 +- .../java/com/google/cloud/mcp/JsonRpc.java | 18 +- .../google/cloud/mcp/McpToolboxClient.java | 8 + .../cloud/mcp/{ => auth}/AuthMethods.java | 2 +- .../cloud/mcp/{ => auth}/AuthResolver.java | 2 +- .../cloud/mcp/{ => auth}/AuthTokenGetter.java | 2 +- .../mcp/{ => auth}/CredentialsProvider.java | 2 +- .../{ => auth}/GoogleCredentialsProvider.java | 2 +- .../cloud/mcp/{ => auth}/ResolvedAuth.java | 3 +- .../{ => client}/McpToolboxClientBuilder.java | 11 +- .../{ => client}/McpToolboxClientImpl.java | 14 +- .../mcp/{ => exception}/McpException.java | 2 +- .../com/google/cloud/mcp/{ => tool}/Tool.java | 5 +- .../cloud/mcp/{ => tool}/ToolDefinition.java | 2 +- .../mcp/{ => tool}/ToolPostProcessor.java | 2 +- .../mcp/{ => tool}/ToolPreProcessor.java | 2 +- .../cloud/mcp/{ => tool}/ToolResult.java | 2 +- .../BaseMcpTransport.java} | 254 ++--------- .../cloud/mcp/transport/HttpMcpTransport.java | 175 ++++++++ .../cloud/mcp/{ => transport}/Transport.java | 2 +- .../{ => transport}/TransportManifest.java | 3 +- .../{ => transport}/TransportResponse.java | 2 +- .../v20241105/HttpMcpTransportV20241105.java | 130 ++++++ .../v20250326/HttpMcpTransportV20250326.java | 146 +++++++ .../v20250618/HttpMcpTransportV20250618.java | 132 ++++++ .../v20251125/HttpMcpTransportV20251125.java | 132 ++++++ .../cloud/mcp/{ => auth}/AuthMethodsTest.java | 2 +- .../HttpMcpToolboxClientTest.java | 4 +- .../McpToolboxClientBuilderTest.java | 9 +- .../McpToolboxClientImplErrorsTest.java | 6 +- .../McpToolboxClientImplHeadersTest.java | 27 +- .../McpToolboxClientImplJsonRpcTest.java | 7 +- .../McpToolboxClientImplTest.java | 50 ++- .../cloud/mcp/e2e/McpToolboxClientTest.java | 6 +- .../google/cloud/mcp/{ => tool}/ToolTest.java | 4 +- .../mcp/{ => tool}/ToolValidationTest.java | 3 +- .../{ => transport}/HttpMcpTransportTest.java | 16 +- 43 files changed, 1371 insertions(+), 685 deletions(-) rename src/main/java/com/google/cloud/mcp/{ => auth}/AuthMethods.java (98%) rename src/main/java/com/google/cloud/mcp/{ => auth}/AuthResolver.java (98%) rename src/main/java/com/google/cloud/mcp/{ => auth}/AuthTokenGetter.java (96%) rename src/main/java/com/google/cloud/mcp/{ => auth}/CredentialsProvider.java (96%) rename src/main/java/com/google/cloud/mcp/{ => auth}/GoogleCredentialsProvider.java (98%) rename src/main/java/com/google/cloud/mcp/{ => auth}/ResolvedAuth.java (97%) rename src/main/java/com/google/cloud/mcp/{ => client}/McpToolboxClientBuilder.java (91%) rename src/main/java/com/google/cloud/mcp/{ => client}/McpToolboxClientImpl.java (95%) rename src/main/java/com/google/cloud/mcp/{ => exception}/McpException.java (96%) rename src/main/java/com/google/cloud/mcp/{ => tool}/Tool.java (97%) rename src/main/java/com/google/cloud/mcp/{ => tool}/ToolDefinition.java (98%) rename src/main/java/com/google/cloud/mcp/{ => tool}/ToolPostProcessor.java (96%) rename src/main/java/com/google/cloud/mcp/{ => tool}/ToolPreProcessor.java (97%) rename src/main/java/com/google/cloud/mcp/{ => tool}/ToolResult.java (97%) rename src/main/java/com/google/cloud/mcp/{HttpMcpTransport.java => transport/BaseMcpTransport.java} (56%) create mode 100644 src/main/java/com/google/cloud/mcp/transport/HttpMcpTransport.java rename src/main/java/com/google/cloud/mcp/{ => transport}/Transport.java (97%) rename src/main/java/com/google/cloud/mcp/{ => transport}/TransportManifest.java (92%) rename src/main/java/com/google/cloud/mcp/{ => transport}/TransportResponse.java (97%) create mode 100644 src/main/java/com/google/cloud/mcp/transport/v20241105/HttpMcpTransportV20241105.java create mode 100644 src/main/java/com/google/cloud/mcp/transport/v20250326/HttpMcpTransportV20250326.java create mode 100644 src/main/java/com/google/cloud/mcp/transport/v20250618/HttpMcpTransportV20250618.java create mode 100644 src/main/java/com/google/cloud/mcp/transport/v20251125/HttpMcpTransportV20251125.java rename src/test/java/com/google/cloud/mcp/{ => auth}/AuthMethodsTest.java (99%) rename src/test/java/com/google/cloud/mcp/{ => client}/HttpMcpToolboxClientTest.java (98%) rename src/test/java/com/google/cloud/mcp/{ => client}/McpToolboxClientBuilderTest.java (94%) rename src/test/java/com/google/cloud/mcp/{ => client}/McpToolboxClientImplErrorsTest.java (97%) rename src/test/java/com/google/cloud/mcp/{ => client}/McpToolboxClientImplHeadersTest.java (92%) rename src/test/java/com/google/cloud/mcp/{ => client}/McpToolboxClientImplJsonRpcTest.java (98%) rename src/test/java/com/google/cloud/mcp/{ => client}/McpToolboxClientImplTest.java (95%) rename src/test/java/com/google/cloud/mcp/{ => tool}/ToolTest.java (99%) rename src/test/java/com/google/cloud/mcp/{ => tool}/ToolValidationTest.java (99%) rename src/test/java/com/google/cloud/mcp/{ => transport}/HttpMcpTransportTest.java (97%) diff --git a/demo-applications/cymbal-transit/pom.xml b/demo-applications/cymbal-transit/pom.xml index 7c420d4..0897eaa 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.2.0 + 0.2.1-SNAPSHOT diff --git a/demo-applications/cymbal-transit/src/main/java/cloudcode/cymbal/CymbalTransitApplication.java b/demo-applications/cymbal-transit/src/main/java/cloudcode/cymbal/CymbalTransitApplication.java index 070c86e..a65ad96 100644 --- a/demo-applications/cymbal-transit/src/main/java/cloudcode/cymbal/CymbalTransitApplication.java +++ b/demo-applications/cymbal-transit/src/main/java/cloudcode/cymbal/CymbalTransitApplication.java @@ -40,6 +40,8 @@ public static void main(final String[] args) throws Exception { // Start the Spring Boot application. app.run(args); logger.info( - "Hello from Cloud Run! The container started successfully and is listening for HTTP requests on " + port); + "Hello from Cloud Run! The container started successfully and is listening for HTTP" + + " requests on " + + port); } } 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 6cea16f..4ef044d 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,250 +16,272 @@ package cloudcode.cymbal.web; -import com.google.cloud.mcp.McpToolboxClient; import cloudcode.cymbal.CymbalTransitApplication; -import com.google.cloud.mcp.AuthTokenGetter; import com.google.auth.oauth2.GoogleCredentials; import com.google.auth.oauth2.IdTokenProvider; - -import org.springframework.boot.SpringApplication; -import org.springframework.boot.autoconfigure.SpringBootApplication; -import org.springframework.web.bind.annotation.*; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Service; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.beans.factory.annotation.Value; - -import javax.annotation.PostConstruct; -import javax.servlet.http.HttpSession; -import java.io.FileInputStream; -import java.util.Collections; -import java.util.Map; -import java.util.concurrent.CompletableFuture; - -// LangChain4j Imports for Agentic Routing & Gemini 3 Flash +import com.google.cloud.mcp.McpToolboxClient; +import com.google.cloud.mcp.auth.AuthTokenGetter; +import dev.langchain4j.agent.tool.Tool; +import dev.langchain4j.memory.chat.MessageWindowChatMemory; import dev.langchain4j.model.chat.ChatLanguageModel; import dev.langchain4j.model.vertexai.VertexAiGeminiChatModel; -import dev.langchain4j.memory.chat.MessageWindowChatMemory; import dev.langchain4j.service.AiServices; -import dev.langchain4j.agent.tool.Tool; -import dev.langchain4j.service.SystemMessage; import dev.langchain4j.service.MemoryId; +import dev.langchain4j.service.SystemMessage; import dev.langchain4j.service.UserMessage; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.CompletableFuture; import java.util.stream.Collectors; +import javax.annotation.PostConstruct; +import javax.servlet.http.HttpSession; +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.Service; +import org.springframework.web.bind.annotation.*; @SpringBootApplication public class CymbalTransitController { - public static void main(String[] args) { - SpringApplication.run(CymbalTransitApplication.class, args); - } + public static void main(String[] args) { + SpringApplication.run(CymbalTransitApplication.class, args); + } } /** - * 1. AI AGENT CONFIGURATION - * Configures Gemini 3 Flash and binds it to our LangChain4j Agent Interface. + * 1. AI AGENT CONFIGURATION Configures Gemini 3 Flash and binds it to our LangChain4j Agent + * Interface. */ @Configuration class AgentConfiguration { - @Value("${GCP_PROJECT_ID:fallback_project_id}") - private String projectId; - - @Value("${GCP_REGION:fallback_region}") - private String region; - - @Value("${GEMINI_MODEL_NAME:fallback_model}") - private String modelName; - - @Bean - ChatLanguageModel geminiChatModel() { - return VertexAiGeminiChatModel.builder() - .project(projectId) - .location(region) - .modelName(modelName) // Utilizing externalized parameters - .build(); - } - - @Bean - TransitAgent transitAgent(ChatLanguageModel chatLanguageModel, TransitAgentTools tools) { - return AiServices.builder(TransitAgent.class) - .chatLanguageModel(chatLanguageModel) - .chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(20)) - .tools(tools) // Exposes our MCP tools to Gemini - .build(); - } + @Value("${GCP_PROJECT_ID:fallback_project_id}") + private String projectId; + + @Value("${GCP_REGION:fallback_region}") + private String region; + + @Value("${GEMINI_MODEL_NAME:fallback_model}") + private String modelName; + + @Bean + ChatLanguageModel geminiChatModel() { + return VertexAiGeminiChatModel.builder() + .project(projectId) + .location(region) + .modelName(modelName) // Utilizing externalized parameters + .build(); + } + + @Bean + TransitAgent transitAgent(ChatLanguageModel chatLanguageModel, TransitAgentTools tools) { + return AiServices.builder(TransitAgent.class) + .chatLanguageModel(chatLanguageModel) + .chatMemoryProvider(memoryId -> MessageWindowChatMemory.withMaxMessages(20)) + .tools(tools) // Exposes our MCP tools to Gemini + .build(); + } } -/** - * 2. THE AI AGENT INTERFACE - * Declarative AI service handling routing logic via System Prompt. - */ +/** 2. THE AI AGENT INTERFACE Declarative AI service handling routing logic via System Prompt. */ interface TransitAgent { - @SystemMessage({ - "You are the Cymbal Transit Concierge.", - "CRITICAL INSTRUCTION: On your very first interaction, you MUST use the 'findAllSchedules' tool to fetch and memorize the broad bus routes.", - "Keep this data handy in your context. Answer general routing questions using this stored data. ", - "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.", - "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 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." - }) - String chat(@MemoryId String sessionId, @UserMessage String userMessage); + @SystemMessage({ + "You are the Cymbal Transit Concierge.", + "CRITICAL INSTRUCTION: On your very first interaction, you MUST use the 'findAllSchedules' tool" + + " to fetch and memorize the broad bus routes.", + "Keep this data handy in your context. Answer general routing questions using this stored data." + + " ", + "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.", + "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" + + " 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." + }) + String chat(@MemoryId String sessionId, @UserMessage String userMessage); } /** - * 3. THE TOOLBOX BRIDGE - * Wraps our asynchronous MCP Client calls into synchronous @Tools that LangChain4j (Gemini) can execute. + * 3. THE TOOLBOX BRIDGE Wraps our asynchronous MCP Client calls into synchronous @Tools that + * LangChain4j (Gemini) can execute. */ @Service class TransitAgentTools { - - private final McpToolboxService mcpService; - - public TransitAgentTools(McpToolboxService mcpService) { - this.mcpService = mcpService; - } - @Tool("Fetches the initial, broad dataset of all available bus schedules and routes. Use this to build your context.") - public String findAllSchedules() { - return mcpService.findAllSchedules().join(); - } - - @Tool("Query specific schedules between an origin and destination city. Use only when the user narrows down their request.") - public String querySchedules(String origin, String destination) { - return mcpService.querySchedules(origin, destination).join(); - } - - @Tool("Book a ticket for a passenger using a specific trip ID.") - public String bookTicket(String tripId, String passengerName) { - return mcpService.bookTicket(tripId, passengerName).join(); - } - - @Tool("Semantic search for transit policies regarding luggage, pets, refunds, and general rules.") - public String searchPolicies(String searchQuery) { - return mcpService.searchPolicies(searchQuery).join(); - } + private final McpToolboxService mcpService; + + public TransitAgentTools(McpToolboxService mcpService) { + this.mcpService = mcpService; + } + + @Tool( + "Fetches the initial, broad dataset of all available bus schedules and routes. Use this to" + + " build your context.") + public String findAllSchedules() { + return mcpService.findAllSchedules().join(); + } + + @Tool( + "Query specific schedules between an origin and destination city. Use only when the user" + + " narrows down their request.") + public String querySchedules(String origin, String destination) { + return mcpService.querySchedules(origin, destination).join(); + } + + @Tool("Book a ticket for a passenger using a specific trip ID.") + public String bookTicket(String tripId, String passengerName) { + return mcpService.bookTicket(tripId, passengerName).join(); + } + + @Tool("Semantic search for transit policies regarding luggage, pets, refunds, and general rules.") + public String searchPolicies(String searchQuery) { + return mcpService.searchPolicies(searchQuery).join(); + } } /** - * 4. THE MCP TOOLBOX SERVICE - * Handles the actual connection and execution against the AlloyDB backend. + * 4. THE MCP TOOLBOX SERVICE Handles the actual connection and execution against the AlloyDB + * backend. */ @Service class McpToolboxService { - - private McpToolboxClient mcpClient; - private String idToken; - - @Value("${MCP_TOOLBOX_URL:fallback_toolbox_url}") - private String targetUrl; - - @PostConstruct - public void init() { - try { - String tokenAudience = targetUrl; - - System.out.println("--- Initializing MCP Toolbox Client ---"); - - GoogleCredentials credentials = GoogleCredentials.getApplicationDefault(); - if (!(credentials instanceof IdTokenProvider)) { - throw new RuntimeException("Loaded credentials do not support ID Tokens."); - } - - this.idToken = ((IdTokenProvider) credentials) - .idTokenWithAudience(tokenAudience, Collections.emptyList()) - .getTokenValue(); - - this.mcpClient = McpToolboxClient.builder() - .baseUrl(targetUrl) - .apiKey(idToken) - .build(); - - mcpClient.listTools().thenAccept(tools -> { - System.out.println("Successfully discovered " + tools.size() + " tools."); - }).join(); - } catch (Exception e) { - System.err.println("Failed to initialize MCP Toolbox Client:"); - e.printStackTrace(); - } - } + private McpToolboxClient mcpClient; + private String idToken; - public CompletableFuture findAllSchedules() { - return mcpClient.invokeTool("find-bus-schedules", Collections.emptyMap()).thenApply(result -> { - if (result.isError() || result.content() == null || result.content().isEmpty()) return "No schedules found."; - //return result.content().get(0).text(); - //return result.text(); - return result.content().stream() - .map(content -> content.text()) - .collect(Collectors.joining(", ", "[", "]")); - }); - } + @Value("${MCP_TOOLBOX_URL:fallback_toolbox_url}") + private String targetUrl; - public CompletableFuture querySchedules(String origin, String destination) { - java.util.Map params = new java.util.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()) return "No specific schedules found."; - System.out.println(result); - return result.content().stream() - .map(content -> content.text()) - .collect(Collectors.joining(", ", "[", "]")); - }); - } + @PostConstruct + public void init() { + try { + String tokenAudience = targetUrl; + + System.out.println("--- Initializing MCP Toolbox Client ---"); + + GoogleCredentials credentials = GoogleCredentials.getApplicationDefault(); + if (!(credentials instanceof IdTokenProvider)) { + throw new RuntimeException("Loaded credentials do not support ID Tokens."); + } + + this.idToken = + ((IdTokenProvider) credentials) + .idTokenWithAudience(tokenAudience, Collections.emptyList()) + .getTokenValue(); - public CompletableFuture bookTicket(String tripId, String passengerName) { - AuthTokenGetter toolAuthGetter = () -> CompletableFuture.completedFuture(idToken); - return mcpClient.loadTool("book-ticket", Collections.singletonMap("google_auth", toolAuthGetter)) - .thenCompose(tool -> { - tool.bindParam("passenger_name", passengerName); - return tool.execute(Collections.singletonMap("trip_id", tripId)); + this.mcpClient = McpToolboxClient.builder().baseUrl(targetUrl).apiKey(idToken).build(); + + mcpClient + .listTools() + .thenAccept( + tools -> { + System.out.println("Successfully discovered " + tools.size() + " tools."); + }) + .join(); + + } catch (Exception e) { + System.err.println("Failed to initialize MCP Toolbox Client:"); + e.printStackTrace(); + } + } + + public CompletableFuture findAllSchedules() { + return mcpClient + .invokeTool("find-bus-schedules", Collections.emptyMap()) + .thenApply( + result -> { + if (result.isError() || result.content() == null || result.content().isEmpty()) + return "No schedules found."; + // return result.content().get(0).text(); + // return result.text(); + return result.content().stream() + .map(content -> content.text()) + .collect(Collectors.joining(", ", "[", "]")); + }); + } + + public CompletableFuture querySchedules(String origin, String destination) { + java.util.Map params = new java.util.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()) + return "No specific schedules found."; + System.out.println(result); + return result.content().stream() + .map(content -> content.text()) + .collect(Collectors.joining(", ", "[", "]")); + }); + } + + public CompletableFuture bookTicket(String tripId, String passengerName) { + AuthTokenGetter toolAuthGetter = () -> CompletableFuture.completedFuture(idToken); + return mcpClient + .loadTool("book-ticket", Collections.singletonMap("google_auth", toolAuthGetter)) + .thenCompose( + tool -> { + tool.bindParam("passenger_name", passengerName); + return tool.execute(Collections.singletonMap("trip_id", tripId)); }) - .thenApply(result -> { - if (result.isError() || result.content() == null || result.content().isEmpty()) { - System.err.println("Tool execution failed: " + result.content().get(0).text()); - return "Transaction failed."; - } - return result.content().get(0).text(); + .thenApply( + result -> { + if (result.isError() || result.content() == null || result.content().isEmpty()) { + System.err.println("Tool execution failed: " + result.content().get(0).text()); + return "Transaction failed."; + } + return result.content().get(0).text(); }); - } - - public CompletableFuture searchPolicies(String searchQuery) { - return mcpClient.invokeTool("search-policies", Map.of("search_query", searchQuery)) - .thenApply(result -> { - if (result.isError() || result.content() == null || result.content().isEmpty()) return "No policy information found."; - return result.content().stream() - .map(content -> content.text()) - .collect(Collectors.joining(", ", "[", "]")); + } + + public CompletableFuture searchPolicies(String searchQuery) { + return mcpClient + .invokeTool("search-policies", Map.of("search_query", searchQuery)) + .thenApply( + result -> { + if (result.isError() || result.content() == null || result.content().isEmpty()) + return "No policy information found."; + return result.content().stream() + .map(content -> content.text()) + .collect(Collectors.joining(", ", "[", "]")); }); - } + } } /** - * 5. THE REST CONTROLLER - * Now radically simplified! No more manual if/else logic or JSON parsing. + * 5. THE REST CONTROLLER Now radically simplified! No more manual if/else logic or JSON parsing. */ @RestController @RequestMapping("/api/agent") class TransitAgentController { - private final TransitAgent transitAgent; + private final TransitAgent transitAgent; - public TransitAgentController(TransitAgent transitAgent) { - this.transitAgent = transitAgent; - } + public TransitAgentController(TransitAgent transitAgent) { + this.transitAgent = transitAgent; + } - @PostMapping("/chat") - public ResponseEntity handleUserChat(@RequestBody String userMessage, HttpSession session) { - // We use the HTTP Session ID to tell LangChain4j which memory context to load - String sessionId = session.getId(); - - // Let Gemini 3 Flash handle the thinking, tool execution, and response generation! - String agentResponse = transitAgent.chat(sessionId, userMessage); - - return ResponseEntity.ok(agentResponse); - } + @PostMapping("/chat") + public ResponseEntity handleUserChat( + @RequestBody String userMessage, HttpSession session) { + // We use the HTTP Session ID to tell LangChain4j which memory context to load + String sessionId = session.getId(); + + // Let Gemini 3 Flash handle the thinking, tool execution, and response generation! + String agentResponse = transitAgent.chat(sessionId, userMessage); + + return ResponseEntity.ok(agentResponse); + } } diff --git a/example/pom.xml b/example/pom.xml index 53ca764..551141e 100644 --- a/example/pom.xml +++ b/example/pom.xml @@ -33,7 +33,7 @@ com.google.cloud.mcp mcp-toolbox-sdk-java - 0.2.0 + 0.2.1-SNAPSHOT diff --git a/example/src/main/java/cloudcode/helloworld/ExampleUsage.java b/example/src/main/java/cloudcode/helloworld/ExampleUsage.java index 504886c..70e18e5 100644 --- a/example/src/main/java/cloudcode/helloworld/ExampleUsage.java +++ b/example/src/main/java/cloudcode/helloworld/ExampleUsage.java @@ -16,145 +16,166 @@ package cloudcode.helloworld; -import java.util.Map; -import java.util.Collections; -import java.util.concurrent.CompletableFuture; -import java.io.FileInputStream; -import com.google.cloud.mcp.McpToolboxClient; -import com.google.cloud.mcp.AuthTokenGetter; import com.google.auth.oauth2.GoogleCredentials; import com.google.auth.oauth2.IdTokenProvider; +import com.google.cloud.mcp.McpToolboxClient; +import com.google.cloud.mcp.auth.AuthTokenGetter; +import java.io.FileInputStream; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.CompletableFuture; /** - * Sample Application to demostrate the usage of the MCP Toolbox Java SDK. - * Covers: Global Auth, Parameterized Auth, Discovery, Simple Tool, Authenticated Tool, Parameter Binding. + * Sample Application to demostrate the usage of the MCP Toolbox Java SDK. Covers: Global Auth, + * Parameterized Auth, Discovery, Simple Tool, Authenticated Tool, Parameter Binding. */ public class ExampleUsage { - public static void main(String[] args) { - // CONFIGURATION - String targetUrl = "YOUR_TOOLBOX_SERVICE_ENDPOINT"; - - // Match the Service URL if using Cloud Run OIDC - String tokenAudience = targetUrl; - - // -------------------------------------------------------------------------------- - // AUTHENTICATION SETUP - // -------------------------------------------------------------------------------- - // FOR LOCAL DEVELOPMENT: Use a Service Account Key JSON file. - // FOR PRODUCTION (Cloud Run): Comment out the 'keyPath' logic and use ADC directly. - // -------------------------------------------------------------------------------- - - String keyPath = "YOUR_CREDENTIALS_JSON_FILE_PATH.json"; - - System.out.println("--- Starting MCP Toolbox Integration Test ---"); - System.out.println("Target Server: " + targetUrl); - -try { - System.out.println(" [Init] Fetching ID Token..."); - - GoogleCredentials credentials; - - // --- OPTION A: LOCAL DEV (Explicit Key File) --- - if (keyPath != null && !keyPath.isEmpty()) { - System.out.println(" [Auth] Using Service Account Key File: " + keyPath); - credentials = GoogleCredentials.fromStream(new FileInputStream(keyPath)); - } - // --- OPTION B: PRODUCTION (ADC) --- - else { - System.out.println(" [Auth] Using Application Default Credentials (ADC)"); - credentials = GoogleCredentials.getApplicationDefault(); - } - - if (!(credentials instanceof IdTokenProvider)) { - throw new RuntimeException("Loaded credentials do not support ID Tokens."); - } - - // Generate Token for the specified Audience - String idToken = ((IdTokenProvider) credentials).idTokenWithAudience(tokenAudience, Collections.emptyList()).getTokenValue(); - System.out.println(" [Debug] Token Generated."); - - // Initialize Client with Global Auth (Applies to ALL calls - Gate 1) - McpToolboxClient client = McpToolboxClient.builder() - .baseUrl(targetUrl) - .apiKey(idToken) - .build(); - - // STEP 1: TEST DISCOVERY METHODS - client.listTools() - .thenCompose(tools -> { - System.out.println("\n[1] listTools(): Success. Found " + tools.size() + " tools."); - return client.loadToolset(); - }) - .thenCompose(tools -> { - System.out.println("[2] loadToolset() (Alias): Success."); - return client.loadToolset("retail") - .handle((res, ex) -> { - if (ex == null) System.out.println("[3] loadToolset('retail'): Found " + res.size() + " tools."); - else System.out.println("[3] loadToolset('retail'): Skipped (Not configured on server)."); - return null; + public static void main(String[] args) { + // CONFIGURATION + String targetUrl = "YOUR_TOOLBOX_SERVICE_ENDPOINT"; + + // Match the Service URL if using Cloud Run OIDC + String tokenAudience = targetUrl; + + // -------------------------------------------------------------------------------- + // AUTHENTICATION SETUP + // -------------------------------------------------------------------------------- + // FOR LOCAL DEVELOPMENT: Use a Service Account Key JSON file. + // FOR PRODUCTION (Cloud Run): Comment out the 'keyPath' logic and use ADC directly. + // -------------------------------------------------------------------------------- + + String keyPath = "YOUR_CREDENTIALS_JSON_FILE_PATH.json"; + + System.out.println("--- Starting MCP Toolbox Integration Test ---"); + System.out.println("Target Server: " + targetUrl); + + try { + System.out.println(" [Init] Fetching ID Token..."); + + GoogleCredentials credentials; + + // --- OPTION A: LOCAL DEV (Explicit Key File) --- + if (keyPath != null && !keyPath.isEmpty()) { + System.out.println(" [Auth] Using Service Account Key File: " + keyPath); + credentials = GoogleCredentials.fromStream(new FileInputStream(keyPath)); + } + // --- OPTION B: PRODUCTION (ADC) --- + else { + System.out.println(" [Auth] Using Application Default Credentials (ADC)"); + credentials = GoogleCredentials.getApplicationDefault(); + } + + if (!(credentials instanceof IdTokenProvider)) { + throw new RuntimeException("Loaded credentials do not support ID Tokens."); + } + + // Generate Token for the specified Audience + String idToken = + ((IdTokenProvider) credentials) + .idTokenWithAudience(tokenAudience, Collections.emptyList()) + .getTokenValue(); + System.out.println(" [Debug] Token Generated."); + + // Initialize Client with Global Auth (Applies to ALL calls - Gate 1) + McpToolboxClient client = + McpToolboxClient.builder().baseUrl(targetUrl).apiKey(idToken).build(); + + // STEP 1: TEST DISCOVERY METHODS + client + .listTools() + .thenCompose( + tools -> { + System.out.println("\n[1] listTools(): Success. Found " + tools.size() + " tools."); + return client.loadToolset(); + }) + .thenCompose( + tools -> { + System.out.println("[2] loadToolset() (Alias): Success."); + return client + .loadToolset("retail") + .handle( + (res, ex) -> { + if (ex == null) + System.out.println( + "[3] loadToolset('retail'): Found " + res.size() + " tools."); + else + System.out.println( + "[3] loadToolset('retail'): Skipped (Not configured on server)."); + return null; }); - }) - .thenCompose(ignore -> { - - // STEP 2: INVOKE TOOL WITHOUT EXTRA AUTH - System.out.println("\n[4] Testing Simple Tool: 'get-retail-facet-filters'..."); - return client.invokeTool("get-retail-facet-filters", Map.of()); - }) - .thenCompose(result -> { - System.out.println(" -> Result: " + (result.content() != null ? "Received Data" : "Empty")); - - // STEP 3: INVOKE TOOL WITH AUTHENTICATED PARAMETERS - System.out.println("\n[5] Testing Authenticated Tool: 'get-toy-price'..."); - - // Define the getter for the 'google_auth' service - AuthTokenGetter toolAuthGetter = () -> CompletableFuture.completedFuture(idToken); - - // Load using the sophisticated overload - return client.loadTool("get-toy-price", Map.of("google_auth", toolAuthGetter)); - }) - .thenCompose(tool -> { - System.out.println(" -> Loaded Tool: " + tool.definition().description()); - - // STEP 4: TEST BINDING PARAMETERS SEQUENTIALLY - System.out.println("\n[A] Executing UNBOUND (Runtime arg: 'barbie')..."); - - return tool.execute(Map.of("description", "barbie")) - .thenCompose(result1 -> { - if (result1.content() != null && !result1.content().isEmpty()) { - System.out - .println(" -> Result (Unbound): " + result1.content().get(0).text()); - } - - // NOW bind the parameter - System.out.println("\n[B] Binding 'description' to 'soft toy'..."); - tool.bindParam("description", "soft toy"); - - System.out.println(" -> Executing BOUND (Runtime arg: 'barbie' - should be IGNORED)..."); - // We pass 'barbie', but expecting 'soft toy' price because of binding override - return tool.execute(Map.of("description", "barbie")); + }) + .thenCompose( + ignore -> { + + // STEP 2: INVOKE TOOL WITHOUT EXTRA AUTH + System.out.println("\n[4] Testing Simple Tool: 'get-retail-facet-filters'..."); + return client.invokeTool("get-retail-facet-filters", Map.of()); + }) + .thenCompose( + result -> { + System.out.println( + " -> Result: " + (result.content() != null ? "Received Data" : "Empty")); + + // STEP 3: INVOKE TOOL WITH AUTHENTICATED PARAMETERS + System.out.println("\n[5] Testing Authenticated Tool: 'get-toy-price'..."); + + // Define the getter for the 'google_auth' service + AuthTokenGetter toolAuthGetter = () -> CompletableFuture.completedFuture(idToken); + + // Load using the sophisticated overload + return client.loadTool("get-toy-price", Map.of("google_auth", toolAuthGetter)); + }) + .thenCompose( + tool -> { + System.out.println(" -> Loaded Tool: " + tool.definition().description()); + + // STEP 4: TEST BINDING PARAMETERS SEQUENTIALLY + System.out.println("\n[A] Executing UNBOUND (Runtime arg: 'barbie')..."); + + return tool.execute(Map.of("description", "barbie")) + .thenCompose( + result1 -> { + if (result1.content() != null && !result1.content().isEmpty()) { + System.out.println( + " -> Result (Unbound): " + result1.content().get(0).text()); + } + + // NOW bind the parameter + System.out.println("\n[B] Binding 'description' to 'soft toy'..."); + tool.bindParam("description", "soft toy"); + + System.out.println( + " -> Executing BOUND (Runtime arg: 'barbie' - should be" + + " IGNORED)..."); + // We pass 'barbie', but expecting 'soft toy' price because of binding + // override + return tool.execute(Map.of("description", "barbie")); }); - }) - .thenAccept(result -> { - System.out.println("\n[6] Final Result (Bound):"); - if (result.isError()) { - System.err.println("Tool execution failed: " + result.content().get(0).text()); - } else if (result.content() != null && !result.content().isEmpty()) { - String output = result.content().get(0).text(); - System.out.println(" " + output.substring(0, Math.min(output.length(), 200)) + "..."); - } else { - System.out.println(" Empty Response"); - } - }) - .exceptionally(ex -> { - System.err.println("\n!!! TEST FAILED !!!"); - ex.printStackTrace(); - return null; - }) - .join(); - - } catch (Exception e) { - e.printStackTrace(); - } - System.out.println("\n--- Test Suite Complete ---"); + }) + .thenAccept( + result -> { + System.out.println("\n[6] Final Result (Bound):"); + if (result.isError()) { + System.err.println("Tool execution failed: " + result.content().get(0).text()); + } else if (result.content() != null && !result.content().isEmpty()) { + String output = result.content().get(0).text(); + System.out.println( + " " + output.substring(0, Math.min(output.length(), 200)) + "..."); + } else { + System.out.println(" Empty Response"); + } + }) + .exceptionally( + ex -> { + System.err.println("\n!!! TEST FAILED !!!"); + ex.printStackTrace(); + return null; + }) + .join(); + + } catch (Exception e) { + e.printStackTrace(); } + System.out.println("\n--- Test Suite Complete ---"); + } } diff --git a/example/src/main/java/cloudcode/helloworld/InputValidationTest.java b/example/src/main/java/cloudcode/helloworld/InputValidationTest.java index b171ae7..4971e25 100644 --- a/example/src/main/java/cloudcode/helloworld/InputValidationTest.java +++ b/example/src/main/java/cloudcode/helloworld/InputValidationTest.java @@ -16,100 +16,101 @@ package cloudcode.helloworld; -import com.google.cloud.mcp.McpToolboxClient; -import com.google.cloud.mcp.Tool; import com.google.auth.oauth2.GoogleCredentials; import com.google.auth.oauth2.IdTokenProvider; +import com.google.cloud.mcp.McpToolboxClient; +import com.google.cloud.mcp.tool.Tool; import java.io.FileInputStream; import java.util.Collections; -import java.util.Map; import java.util.HashMap; +import java.util.Map; import java.util.concurrent.CompletableFuture; public class InputValidationTest { - public static void main(String[] args) { - String targetUrl = "YOUR_TOOLBOX_SERVICE_ENDPOINT"; - String tokenAudience = targetUrl; - // -------------------------------------------------------------------------------- - // AUTHENTICATION SETUP - // -------------------------------------------------------------------------------- - // FOR LOCAL DEVELOPMENT: Use a Service Account Key JSON file. - // FOR PRODUCTION (Cloud Run): Comment out the 'keyPath' logic and use ADC directly. - // -------------------------------------------------------------------------------- + public static void main(String[] args) { + String targetUrl = "YOUR_TOOLBOX_SERVICE_ENDPOINT"; + String tokenAudience = targetUrl; + // -------------------------------------------------------------------------------- + // AUTHENTICATION SETUP + // -------------------------------------------------------------------------------- + // FOR LOCAL DEVELOPMENT: Use a Service Account Key JSON file. + // FOR PRODUCTION (Cloud Run): Comment out the 'keyPath' logic and use ADC directly. + // -------------------------------------------------------------------------------- - String keyPath = "/YOUR_CREDENTIALS_JSON_FILE_PATH.json"; + String keyPath = "/YOUR_CREDENTIALS_JSON_FILE_PATH.json"; - System.out.println("--- Starting MCP Toolbox Input Validation Test ---"); + System.out.println("--- Starting MCP Toolbox Input Validation Test ---"); - try { - // 1. Setup Auth (Same as before) - System.out.println(" [Init] Fetching ID Token..."); - GoogleCredentials credentials = GoogleCredentials.fromStream(new FileInputStream(keyPath)); - if (!(credentials instanceof IdTokenProvider)) { - throw new RuntimeException("Loaded credentials do not support ID Tokens."); - } - String idToken = ((IdTokenProvider) credentials).idTokenWithAudience(tokenAudience, Collections.emptyList()).getTokenValue(); + try { + // 1. Setup Auth (Same as before) + System.out.println(" [Init] Fetching ID Token..."); + GoogleCredentials credentials = GoogleCredentials.fromStream(new FileInputStream(keyPath)); + if (!(credentials instanceof IdTokenProvider)) { + throw new RuntimeException("Loaded credentials do not support ID Tokens."); + } + String idToken = + ((IdTokenProvider) credentials) + .idTokenWithAudience(tokenAudience, Collections.emptyList()) + .getTokenValue(); - // 2. Initialize Client - McpToolboxClient client = McpToolboxClient.builder() - .baseUrl(targetUrl) - .build(); + // 2. Initialize Client + McpToolboxClient client = McpToolboxClient.builder().baseUrl(targetUrl).build(); - // 3. Load the Tool - // We MUST use loadTool() because validation relies on the ToolDefinition fetched from the server. - System.out.println(" [Init] Loading tool 'get-toy-price'..."); - Tool tool = client.loadTool("get-toy-price").join(); + // 3. Load the Tool + // We MUST use loadTool() because validation relies on the ToolDefinition fetched from the + // server. + System.out.println(" [Init] Loading tool 'get-toy-price'..."); + Tool tool = client.loadTool("get-toy-price").join(); - // 4. Register Auth - // We manually register the token getter so the Tool object can inject the header. - tool.addAuthTokenGetter("google_auth", () -> CompletableFuture.completedFuture(idToken)); + // 4. Register Auth + // We manually register the token getter so the Tool object can inject the header. + tool.addAuthTokenGetter("google_auth", () -> CompletableFuture.completedFuture(idToken)); + // --- Test Case A: Valid Input --- + System.out.println("\n[Test A] Sending VALID input (String)..."); + try { + Map validArgs = Map.of("description", "barbie"); + var result = tool.execute(validArgs).join(); + System.out.println( + " ✅ Success! Output: " + + (result.content().isEmpty() ? "Empty" : result.content().get(0).text())); + } catch (Exception e) { + System.err.println(" ❌ Unexpected failure: " + e.getMessage()); + e.printStackTrace(); + } - // --- Test Case A: Valid Input --- - System.out.println("\n[Test A] Sending VALID input (String)..."); - try { - Map validArgs = Map.of("description", "barbie"); - var result = tool.execute(validArgs).join(); - System.out.println(" ✅ Success! Output: " + (result.content().isEmpty() ? "Empty" : result.content().get(0).text())); - } catch (Exception e) { - System.err.println(" ❌ Unexpected failure: " + e.getMessage()); - e.printStackTrace(); - } + // --- Test Case B: Invalid Type (Int instead of String) --- + System.out.println("\n[Test B] Sending INVALID input (Integer instead of String)..."); + try { + // The 'description' parameter is defined as type: string. We pass an Integer. + Map invalidArgs = Map.of("description", 12345); + tool.execute(invalidArgs).join(); + System.err.println(" ❌ FAILED: Validation did not catch the error!"); + } catch (Exception e) { + // We expect a RuntimeException wrapping IllegalArgumentException + Throwable cause = e.getCause(); + System.out.println(" ✅ Caught Expected Error: " + cause.getMessage()); + } - // --- Test Case B: Invalid Type (Int instead of String) --- - System.out.println("\n[Test B] Sending INVALID input (Integer instead of String)..."); - try { - // The 'description' parameter is defined as type: string. We pass an Integer. - Map invalidArgs = Map.of("description", 12345); - - tool.execute(invalidArgs).join(); - System.err.println(" ❌ FAILED: Validation did not catch the error!"); - } catch (Exception e) { - // We expect a RuntimeException wrapping IllegalArgumentException - Throwable cause = e.getCause(); - System.out.println(" ✅ Caught Expected Error: " + cause.getMessage()); - } - + // --- Test Case C: Null Value (Filtering) --- + System.out.println("\n[Test C] Sending NULL value (should be filtered)..."); + try { + // We use a HashMap because Map.of doesn't allow nulls + Map nullArgs = new HashMap<>(); + nullArgs.put("description", "barbie"); // Valid param + nullArgs.put("some_optional_param", null); // Null param - // --- Test Case C: Null Value (Filtering) --- - System.out.println("\n[Test C] Sending NULL value (should be filtered)..."); - try { - // We use a HashMap because Map.of doesn't allow nulls - Map nullArgs = new HashMap<>(); - nullArgs.put("description", "barbie"); // Valid param - nullArgs.put("some_optional_param", null); // Null param - - // If validation works, 'some_optional_param' will be removed before sending - var result = tool.execute(nullArgs).join(); - System.out.println(" ✅ Success! Null value was filtered and request succeeded."); - } catch (Exception e) { - System.out.println(" ❌ Result: " + e.getCause().getMessage()); - } + // If validation works, 'some_optional_param' will be removed before sending + var result = tool.execute(nullArgs).join(); + System.out.println(" ✅ Success! Null value was filtered and request succeeded."); + } catch (Exception e) { + System.out.println(" ❌ Result: " + e.getCause().getMessage()); + } - } catch (Exception e) { - e.printStackTrace(); - } - System.out.println("\n--- Done ---"); + } catch (Exception e) { + e.printStackTrace(); } + System.out.println("\n--- Done ---"); + } } diff --git a/example/src/main/java/cloudcode/helloworld/StrictFlagTest.java b/example/src/main/java/cloudcode/helloworld/StrictFlagTest.java index ba32ff9..df01a8c 100644 --- a/example/src/main/java/cloudcode/helloworld/StrictFlagTest.java +++ b/example/src/main/java/cloudcode/helloworld/StrictFlagTest.java @@ -17,18 +17,16 @@ package cloudcode.helloworld; import com.google.cloud.mcp.McpToolboxClient; -import com.google.cloud.mcp.Tool; -import java.util.Map; +import com.google.cloud.mcp.tool.Tool; import java.util.HashMap; +import java.util.Map; public class StrictFlagTest { public static void main(String[] args) { String targetUrl = "YOUR_TOOLBOX_SERVICE_ENDPOINT"; System.out.println("--- Starting MCP Toolbox Strict Flag Test ---"); - McpToolboxClient client = McpToolboxClient.builder() - .baseUrl(targetUrl) - .build(); + McpToolboxClient client = McpToolboxClient.builder().baseUrl(targetUrl).build(); // Prepare bindings for a NON-EXISTENT tool Map> paramBinds = new HashMap<>(); @@ -38,7 +36,8 @@ public static void main(String[] args) { System.out.println("\n[Test 1] Loading with Strict = FALSE..."); try { Map tools = client.loadToolset(null, paramBinds, null, false).join(); - System.out.println(" ✅ Success! Loaded " + tools.size() + " tools. Unknown binding was ignored."); + System.out.println( + " ✅ Success! Loaded " + tools.size() + " tools. Unknown binding was ignored."); } catch (Exception e) { System.err.println(" ❌ Failed unexpectedly: " + e.getMessage()); } @@ -60,4 +59,4 @@ public static void main(String[] args) { System.out.println("\n--- Done ---"); } -} \ No newline at end of file +} diff --git a/src/main/java/com/google/cloud/mcp/JsonRpc.java b/src/main/java/com/google/cloud/mcp/JsonRpc.java index 902ac2a..7bc7501 100644 --- a/src/main/java/com/google/cloud/mcp/JsonRpc.java +++ b/src/main/java/com/google/cloud/mcp/JsonRpc.java @@ -19,47 +19,47 @@ import java.util.Map; import java.util.UUID; -class JsonRpc { - static class Request { +public class JsonRpc { + public static class Request { public String jsonrpc = "2.0"; public String id; public String method; public Object params; - public Request(String method, Object params) { + public Request(final String method, final Object params) { this.id = UUID.randomUUID().toString(); this.method = method; this.params = params; } } - static class Notification { + public static class Notification { public String jsonrpc = "2.0"; public String method; public Object params; - public Notification(String method, Object params) { + public Notification(final String method, final Object params) { this.method = method; this.params = params; } } - static class CallToolParams { + public static class CallToolParams { public String name; public Map arguments; - public CallToolParams(String name, Map arguments) { + public CallToolParams(final String name, final Map arguments) { this.name = name; this.arguments = arguments; } } - static class InitializeParams { + public static class InitializeParams { public String protocolVersion; public Map capabilities; public Map clientInfo; - public InitializeParams(String version, String clientName) { + public InitializeParams(final String version, final String clientName) { this.protocolVersion = version; this.capabilities = Map.of(); this.clientInfo = Map.of("name", clientName, "version", "1.0.0"); diff --git a/src/main/java/com/google/cloud/mcp/McpToolboxClient.java b/src/main/java/com/google/cloud/mcp/McpToolboxClient.java index 472a6f3..ff79e04 100644 --- a/src/main/java/com/google/cloud/mcp/McpToolboxClient.java +++ b/src/main/java/com/google/cloud/mcp/McpToolboxClient.java @@ -16,6 +16,14 @@ package com.google.cloud.mcp; +import com.google.cloud.mcp.auth.AuthTokenGetter; +import com.google.cloud.mcp.auth.CredentialsProvider; +import com.google.cloud.mcp.client.McpToolboxClientBuilder; +import com.google.cloud.mcp.tool.Tool; +import com.google.cloud.mcp.tool.ToolDefinition; +import com.google.cloud.mcp.tool.ToolPostProcessor; +import com.google.cloud.mcp.tool.ToolPreProcessor; +import com.google.cloud.mcp.tool.ToolResult; import java.util.Map; import java.util.concurrent.CompletableFuture; diff --git a/src/main/java/com/google/cloud/mcp/AuthMethods.java b/src/main/java/com/google/cloud/mcp/auth/AuthMethods.java similarity index 98% rename from src/main/java/com/google/cloud/mcp/AuthMethods.java rename to src/main/java/com/google/cloud/mcp/auth/AuthMethods.java index cb3815f..a287009 100644 --- a/src/main/java/com/google/cloud/mcp/AuthMethods.java +++ b/src/main/java/com/google/cloud/mcp/auth/AuthMethods.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.auth; import com.google.auth.oauth2.GoogleCredentials; import com.google.auth.oauth2.IdTokenProvider; diff --git a/src/main/java/com/google/cloud/mcp/AuthResolver.java b/src/main/java/com/google/cloud/mcp/auth/AuthResolver.java similarity index 98% rename from src/main/java/com/google/cloud/mcp/AuthResolver.java rename to src/main/java/com/google/cloud/mcp/auth/AuthResolver.java index 109c6ba..0fccadd 100644 --- a/src/main/java/com/google/cloud/mcp/AuthResolver.java +++ b/src/main/java/com/google/cloud/mcp/auth/AuthResolver.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.auth; import java.util.HashMap; import java.util.List; diff --git a/src/main/java/com/google/cloud/mcp/AuthTokenGetter.java b/src/main/java/com/google/cloud/mcp/auth/AuthTokenGetter.java similarity index 96% rename from src/main/java/com/google/cloud/mcp/AuthTokenGetter.java rename to src/main/java/com/google/cloud/mcp/auth/AuthTokenGetter.java index 5d3f736..6067352 100644 --- a/src/main/java/com/google/cloud/mcp/AuthTokenGetter.java +++ b/src/main/java/com/google/cloud/mcp/auth/AuthTokenGetter.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.auth; import java.util.concurrent.CompletableFuture; diff --git a/src/main/java/com/google/cloud/mcp/CredentialsProvider.java b/src/main/java/com/google/cloud/mcp/auth/CredentialsProvider.java similarity index 96% rename from src/main/java/com/google/cloud/mcp/CredentialsProvider.java rename to src/main/java/com/google/cloud/mcp/auth/CredentialsProvider.java index eb428a0..a9c7ca4 100644 --- a/src/main/java/com/google/cloud/mcp/CredentialsProvider.java +++ b/src/main/java/com/google/cloud/mcp/auth/CredentialsProvider.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.auth; import java.util.concurrent.CompletableFuture; diff --git a/src/main/java/com/google/cloud/mcp/GoogleCredentialsProvider.java b/src/main/java/com/google/cloud/mcp/auth/GoogleCredentialsProvider.java similarity index 98% rename from src/main/java/com/google/cloud/mcp/GoogleCredentialsProvider.java rename to src/main/java/com/google/cloud/mcp/auth/GoogleCredentialsProvider.java index 11a7232..bb1490e 100644 --- a/src/main/java/com/google/cloud/mcp/GoogleCredentialsProvider.java +++ b/src/main/java/com/google/cloud/mcp/auth/GoogleCredentialsProvider.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.auth; import com.google.auth.oauth2.GoogleCredentials; import java.io.IOException; diff --git a/src/main/java/com/google/cloud/mcp/ResolvedAuth.java b/src/main/java/com/google/cloud/mcp/auth/ResolvedAuth.java similarity index 97% rename from src/main/java/com/google/cloud/mcp/ResolvedAuth.java rename to src/main/java/com/google/cloud/mcp/auth/ResolvedAuth.java index 017d7e7..e79ecfb 100644 --- a/src/main/java/com/google/cloud/mcp/ResolvedAuth.java +++ b/src/main/java/com/google/cloud/mcp/auth/ResolvedAuth.java @@ -14,8 +14,9 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.auth; +import com.google.cloud.mcp.tool.ToolDefinition; import java.util.Map; /** Represents a resolved set of authentication credentials for a tool execution. */ diff --git a/src/main/java/com/google/cloud/mcp/McpToolboxClientBuilder.java b/src/main/java/com/google/cloud/mcp/client/McpToolboxClientBuilder.java similarity index 91% rename from src/main/java/com/google/cloud/mcp/McpToolboxClientBuilder.java rename to src/main/java/com/google/cloud/mcp/client/McpToolboxClientBuilder.java index 7c2f765..0b66c13 100644 --- a/src/main/java/com/google/cloud/mcp/McpToolboxClientBuilder.java +++ b/src/main/java/com/google/cloud/mcp/client/McpToolboxClientBuilder.java @@ -14,8 +14,15 @@ * limitations under the License. */ -package com.google.cloud.mcp; - +package com.google.cloud.mcp.client; + +import com.google.cloud.mcp.McpToolboxClient; +import com.google.cloud.mcp.ProtocolVersion; +import com.google.cloud.mcp.auth.CredentialsProvider; +import com.google.cloud.mcp.tool.ToolPostProcessor; +import com.google.cloud.mcp.tool.ToolPreProcessor; +import com.google.cloud.mcp.transport.HttpMcpTransport; +import com.google.cloud.mcp.transport.Transport; import java.util.ArrayList; import java.util.HashMap; import java.util.List; diff --git a/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java b/src/main/java/com/google/cloud/mcp/client/McpToolboxClientImpl.java similarity index 95% rename from src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java rename to src/main/java/com/google/cloud/mcp/client/McpToolboxClientImpl.java index c5d254b..d8ebdd2 100644 --- a/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java +++ b/src/main/java/com/google/cloud/mcp/client/McpToolboxClientImpl.java @@ -14,10 +14,22 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.client; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.cloud.mcp.McpToolboxClient; +import com.google.cloud.mcp.auth.AuthTokenGetter; +import com.google.cloud.mcp.auth.CredentialsProvider; +import com.google.cloud.mcp.tool.Tool; +import com.google.cloud.mcp.tool.ToolDefinition; +import com.google.cloud.mcp.tool.ToolPostProcessor; +import com.google.cloud.mcp.tool.ToolPreProcessor; +import com.google.cloud.mcp.tool.ToolResult; +import com.google.cloud.mcp.transport.HttpMcpTransport; +import com.google.cloud.mcp.transport.Transport; +import com.google.cloud.mcp.transport.TransportManifest; +import com.google.cloud.mcp.transport.TransportResponse; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; diff --git a/src/main/java/com/google/cloud/mcp/McpException.java b/src/main/java/com/google/cloud/mcp/exception/McpException.java similarity index 96% rename from src/main/java/com/google/cloud/mcp/McpException.java rename to src/main/java/com/google/cloud/mcp/exception/McpException.java index c057016..9016674 100644 --- a/src/main/java/com/google/cloud/mcp/McpException.java +++ b/src/main/java/com/google/cloud/mcp/exception/McpException.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.exception; /** Unchecked exception thrown for MCP Toolbox Client operations and protocol failures. */ public class McpException extends RuntimeException { diff --git a/src/main/java/com/google/cloud/mcp/Tool.java b/src/main/java/com/google/cloud/mcp/tool/Tool.java similarity index 97% rename from src/main/java/com/google/cloud/mcp/Tool.java rename to src/main/java/com/google/cloud/mcp/tool/Tool.java index 49cfe59..4fb0229 100644 --- a/src/main/java/com/google/cloud/mcp/Tool.java +++ b/src/main/java/com/google/cloud/mcp/tool/Tool.java @@ -14,8 +14,11 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.tool; +import com.google.cloud.mcp.McpToolboxClient; +import com.google.cloud.mcp.auth.AuthResolver; +import com.google.cloud.mcp.auth.AuthTokenGetter; import java.util.ArrayList; import java.util.HashMap; import java.util.List; diff --git a/src/main/java/com/google/cloud/mcp/ToolDefinition.java b/src/main/java/com/google/cloud/mcp/tool/ToolDefinition.java similarity index 98% rename from src/main/java/com/google/cloud/mcp/ToolDefinition.java rename to src/main/java/com/google/cloud/mcp/tool/ToolDefinition.java index ac8e60b..bef492f 100644 --- a/src/main/java/com/google/cloud/mcp/ToolDefinition.java +++ b/src/main/java/com/google/cloud/mcp/tool/ToolDefinition.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.tool; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/google/cloud/mcp/ToolPostProcessor.java b/src/main/java/com/google/cloud/mcp/tool/ToolPostProcessor.java similarity index 96% rename from src/main/java/com/google/cloud/mcp/ToolPostProcessor.java rename to src/main/java/com/google/cloud/mcp/tool/ToolPostProcessor.java index 09000bb..61280ea 100644 --- a/src/main/java/com/google/cloud/mcp/ToolPostProcessor.java +++ b/src/main/java/com/google/cloud/mcp/tool/ToolPostProcessor.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.tool; import java.util.concurrent.CompletableFuture; diff --git a/src/main/java/com/google/cloud/mcp/ToolPreProcessor.java b/src/main/java/com/google/cloud/mcp/tool/ToolPreProcessor.java similarity index 97% rename from src/main/java/com/google/cloud/mcp/ToolPreProcessor.java rename to src/main/java/com/google/cloud/mcp/tool/ToolPreProcessor.java index a280a85..190e38d 100644 --- a/src/main/java/com/google/cloud/mcp/ToolPreProcessor.java +++ b/src/main/java/com/google/cloud/mcp/tool/ToolPreProcessor.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.tool; import java.util.Map; import java.util.concurrent.CompletableFuture; diff --git a/src/main/java/com/google/cloud/mcp/ToolResult.java b/src/main/java/com/google/cloud/mcp/tool/ToolResult.java similarity index 97% rename from src/main/java/com/google/cloud/mcp/ToolResult.java rename to src/main/java/com/google/cloud/mcp/tool/ToolResult.java index d447adc..29b6a61 100644 --- a/src/main/java/com/google/cloud/mcp/ToolResult.java +++ b/src/main/java/com/google/cloud/mcp/tool/ToolResult.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.tool; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; diff --git a/src/main/java/com/google/cloud/mcp/HttpMcpTransport.java b/src/main/java/com/google/cloud/mcp/transport/BaseMcpTransport.java similarity index 56% rename from src/main/java/com/google/cloud/mcp/HttpMcpTransport.java rename to src/main/java/com/google/cloud/mcp/transport/BaseMcpTransport.java index 6cfcc86..6a60297 100644 --- a/src/main/java/com/google/cloud/mcp/HttpMcpTransport.java +++ b/src/main/java/com/google/cloud/mcp/transport/BaseMcpTransport.java @@ -14,10 +14,14 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.transport; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.cloud.mcp.JsonRpc; +import com.google.cloud.mcp.ProtocolVersion; +import com.google.cloud.mcp.auth.CredentialsProvider; +import com.google.cloud.mcp.tool.ToolDefinition; import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -33,91 +37,29 @@ import java.util.concurrent.CompletableFuture; import java.util.logging.Logger; -/** Default HTTP transport implementation using Java 11 HttpClient. */ -public final class HttpMcpTransport implements Transport { +public abstract class BaseMcpTransport implements Transport { - private static final Logger logger = Logger.getLogger(HttpMcpTransport.class.getName()); - private static final String HTTP_WARNING = + protected static final Logger logger = Logger.getLogger(BaseMcpTransport.class.getName()); + protected static final String HTTP_WARNING = "This connection is using HTTP. To prevent credential exposure, please ensure all" + " communication is sent over HTTPS."; - private final String baseUrl; - private final Map clientHeaders; - private final CredentialsProvider credentialsProvider; - private final HttpClient httpClient; - private final ObjectMapper objectMapper; - private final ProtocolVersion preferredProtocolVersion; - private final Object initLock = new Object(); - private CompletableFuture initFuture; - private volatile ProtocolVersion negotiatedProtocolVersion; - private volatile String sessionId; - - /** - * Constructs a new HttpMcpTransport with a base URL. - * - * @param baseUrl The base URL of the remote service. - */ - public HttpMcpTransport(String baseUrl) { - this(baseUrl, Map.of(), (CredentialsProvider) null); - } - - /** - * Constructs a new HttpMcpTransport with base URL and default headers. - * - * @param baseUrl The base URL of the remote service. - * @param clientHeaders Default HTTP headers to include in every request. - */ - public HttpMcpTransport(String baseUrl, Map clientHeaders) { - this(baseUrl, clientHeaders, (CredentialsProvider) null); - } - - /** - * Constructs a new HttpMcpTransport with base URL, default headers and credentials provider. - * - * @param baseUrl The base URL of the remote service. - * @param clientHeaders Default HTTP headers to include in every request. - * @param credentialsProvider Provider for retrieving authorization credentials. - */ - public HttpMcpTransport( - String baseUrl, Map clientHeaders, CredentialsProvider credentialsProvider) { - this(baseUrl, clientHeaders, credentialsProvider, null, null, null); - } - - /** - * Constructs a HttpMcpTransport. - * - * @param baseUrl The base URL of the remote service. - * @param clientHeaders Default HTTP headers to include in every request. - * @param preferredProtocolVersion Preferred MCP protocol version. - * @param httpClient Custom HTTP Client. - * @param executor Optional Executor for handling async requests. - */ - public HttpMcpTransport( - String baseUrl, - Map clientHeaders, - ProtocolVersion preferredProtocolVersion, - HttpClient httpClient, - java.util.concurrent.Executor executor) { - this(baseUrl, clientHeaders, null, preferredProtocolVersion, httpClient, executor); - } - - /** - * Primary constructor for HttpMcpTransport. - * - * @param baseUrl The base URL of the remote service. - * @param clientHeaders Default HTTP headers to include in every request. - * @param credentialsProvider Provider for retrieving authorization credentials. - * @param preferredProtocolVersion Preferred MCP protocol version. - * @param httpClient Custom HTTP Client. - * @param executor Optional Executor for handling async requests. - */ - public HttpMcpTransport( - String baseUrl, - Map clientHeaders, - CredentialsProvider credentialsProvider, - ProtocolVersion preferredProtocolVersion, - HttpClient httpClient, - java.util.concurrent.Executor executor) { + protected final String baseUrl; + protected final Map clientHeaders; + protected final CredentialsProvider credentialsProvider; + protected final HttpClient httpClient; + protected final ObjectMapper objectMapper; + protected final ProtocolVersion preferredProtocolVersion; + protected final Object initLock = new Object(); + protected CompletableFuture initFuture; + + protected BaseMcpTransport( + final String baseUrl, + final Map clientHeaders, + final CredentialsProvider credentialsProvider, + final ProtocolVersion preferredProtocolVersion, + final HttpClient httpClient, + final java.util.concurrent.Executor executor) { if (baseUrl == null || baseUrl.isEmpty()) { throw new IllegalArgumentException("Base URL must be provided"); } @@ -146,28 +88,13 @@ public HttpMcpTransport( this.objectMapper = new ObjectMapper(); } - HttpMcpTransport(String baseUrl, HttpClient httpClient) { - this(baseUrl, Map.of(), null, null, httpClient, null); - } - - HttpMcpTransport(String baseUrl, Map clientHeaders, HttpClient httpClient) { - this(baseUrl, clientHeaders, null, null, httpClient, null); - } - - HttpMcpTransport( - String baseUrl, - Map clientHeaders, - CredentialsProvider credentialsProvider, - HttpClient httpClient) { - this(baseUrl, clientHeaders, credentialsProvider, null, httpClient, null); - } - @Override - public String getBaseUrl() { + public final String getBaseUrl() { return this.baseUrl; } - private CompletableFuture> mergeHeaders(Map extraMetadata) { + final CompletableFuture> mergeHeaders( + final Map extraMetadata) { CompletableFuture authFuture = this.credentialsProvider != null ? this.credentialsProvider.getAuthorizationHeader() @@ -234,7 +161,7 @@ private CompletableFuture> mergeHeaders(Map }); } - private CompletableFuture ensureInitialized(Map extraMetadata) { + final CompletableFuture ensureInitialized(final Map extraMetadata) { synchronized (initLock) { if (initFuture == null) { Map handshakeMetadata = new HashMap<>(); @@ -260,118 +187,14 @@ private CompletableFuture ensureInitialized(Map extraMetad } } - private CompletableFuture performInitialization( - String authHeader, Map handshakeHeaders) { - try { - if (this.baseUrl.toLowerCase(java.util.Locale.ROOT).startsWith("http://") - && authHeader != null) { - logger.warning(HTTP_WARNING); - } - JsonRpc.Request initReq = - new JsonRpc.Request( - "initialize", - new JsonRpc.InitializeParams( - preferredProtocolVersion.getValue(), "mcp-toolbox-sdk-java")); - String body = objectMapper.writeValueAsString(initReq); - HttpRequest.Builder req = - HttpRequest.newBuilder() - .uri(URI.create(baseUrl)) - .POST(HttpRequest.BodyPublishers.ofString(body)); - - handshakeHeaders.forEach(req::setHeader); - applyProtocolHeaders(req); - - return httpClient - .sendAsync(req.build(), HttpResponse.BodyHandlers.ofString()) - .thenCompose( - res -> { - if (res.statusCode() != 200) { - return CompletableFuture.failedFuture( - new McpException("Init failed: " + res.statusCode() + " " + res.body())); - } - try { - JsonNode responseJson = objectMapper.readTree(res.body()); - if (responseJson.has("error")) { - return CompletableFuture.failedFuture( - new McpException("MCP Error: " + responseJson.get("error").toString())); - } - JsonNode result = responseJson.get("result"); - String serverVersion; - if (result != null && result.has("protocolVersion")) { - serverVersion = result.get("protocolVersion").asText(); - } else { - // Fallback to the client's preferred version for backward-compatible/mock - // servers - serverVersion = preferredProtocolVersion.getValue(); - } + protected abstract CompletableFuture performInitialization( + final String authHeader, final Map handshakeHeaders); - // Verify strict compliance with Python/Go behavior - if (!preferredProtocolVersion.getValue().equals(serverVersion)) { - return CompletableFuture.failedFuture( - new McpException( - "MCP version mismatch: client (" - + preferredProtocolVersion.getValue() - + ") != server (" - + serverVersion - + ")")); - } - - this.negotiatedProtocolVersion = ProtocolVersion.fromString(serverVersion); - - if (negotiatedProtocolVersion == ProtocolVersion.VERSION_2025_03_26) { - java.util.Optional sessionIdOpt = - res.headers().firstValue("Mcp-Session-Id"); - if (sessionIdOpt.isEmpty()) { - return CompletableFuture.failedFuture( - new McpException( - "Server did not return a Mcp-Session-Id header during" - + " initialization.")); - } - this.sessionId = sessionIdOpt.get(); - } - - JsonRpc.Notification notif = - new JsonRpc.Notification("notifications/initialized", Map.of()); - String notifBody = objectMapper.writeValueAsString(notif); - HttpRequest.Builder nReq = - HttpRequest.newBuilder() - .uri(URI.create(baseUrl)) - .POST(HttpRequest.BodyPublishers.ofString(notifBody)); - - handshakeHeaders.forEach(nReq::setHeader); - applyProtocolHeaders(nReq); - - return httpClient - .sendAsync(nReq.build(), HttpResponse.BodyHandlers.ofString()) - .thenAccept(nRes -> {}); - } catch (Exception e) { - return CompletableFuture.failedFuture(e); - } - }); - } catch (Exception e) { - return CompletableFuture.failedFuture(e); - } - } - - private void applyProtocolHeaders(HttpRequest.Builder builder) { - builder.header("Content-Type", "application/json"); - if (negotiatedProtocolVersion == null) { - return; - } - if (negotiatedProtocolVersion.requiresAcceptJson()) { - builder.header("Accept", "application/json"); - } - if (negotiatedProtocolVersion.requiresVersionHeader()) { - builder.header("MCP-Protocol-Version", negotiatedProtocolVersion.getValue()); - } - if (negotiatedProtocolVersion.requiresSessionIdHeader() && sessionId != null) { - builder.header("Mcp-Session-Id", sessionId); - } - } + protected abstract void applyProtocolHeaders(final HttpRequest.Builder builder); @Override - public CompletableFuture listTools( - String toolsetName, Map metadata) { + public final CompletableFuture listTools( + final String toolsetName, final Map metadata) { if (this.baseUrl.toLowerCase(java.util.Locale.ROOT).startsWith("http://") && !metadata.isEmpty()) { logger.warning(HTTP_WARNING); @@ -402,8 +225,10 @@ public CompletableFuture listTools( } @Override - public CompletableFuture invokeTool( - String toolName, Map arguments, Map metadata) { + public final CompletableFuture invokeTool( + final String toolName, + final Map arguments, + final Map metadata) { if (this.baseUrl.toLowerCase(java.util.Locale.ROOT).startsWith("http://") && !metadata.isEmpty()) { logger.warning(HTTP_WARNING); @@ -440,10 +265,11 @@ public void close() { // No-op for HttpClient in Java 11 } - private TransportManifest handleListToolsResponse(HttpResponse response) { - if (response.statusCode() != 200) + private TransportManifest handleListToolsResponse(final HttpResponse response) { + if (response.statusCode() != 200) { throw new RuntimeException( "Failed to list tools. Status: " + response.statusCode() + " " + response.body()); + } try { JsonNode root = objectMapper.readTree(response.body()); if (root.has("error")) { diff --git a/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransport.java b/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransport.java new file mode 100644 index 0000000..d5833fc --- /dev/null +++ b/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransport.java @@ -0,0 +1,175 @@ +/* + * 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.transport; + +import com.google.cloud.mcp.ProtocolVersion; +import com.google.cloud.mcp.auth.CredentialsProvider; +import com.google.cloud.mcp.transport.v20241105.HttpMcpTransportV20241105; +import com.google.cloud.mcp.transport.v20250326.HttpMcpTransportV20250326; +import com.google.cloud.mcp.transport.v20250618.HttpMcpTransportV20250618; +import com.google.cloud.mcp.transport.v20251125.HttpMcpTransportV20251125; +import java.net.http.HttpClient; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** Default HTTP transport implementation routing requests to version-specific handlers. */ +public final class HttpMcpTransport implements Transport { + + private final Transport delegate; + + /** + * Constructs a new HttpMcpTransport with a base URL. + * + * @param baseUrl The base URL of the remote service. + */ + public HttpMcpTransport(final String baseUrl) { + this(baseUrl, Map.of(), (CredentialsProvider) null); + } + + /** + * Constructs a new HttpMcpTransport with base URL and default headers. + * + * @param baseUrl The base URL of the remote service. + * @param clientHeaders Default HTTP headers to include in every request. + */ + public HttpMcpTransport(final String baseUrl, final Map clientHeaders) { + this(baseUrl, clientHeaders, (CredentialsProvider) null); + } + + /** + * Constructs a new HttpMcpTransport with base URL, default headers and credentials provider. + * + * @param baseUrl The base URL of the remote service. + * @param clientHeaders Default HTTP headers to include in every request. + * @param credentialsProvider Provider for retrieving authorization credentials. + */ + public HttpMcpTransport( + final String baseUrl, + final Map clientHeaders, + final CredentialsProvider credentialsProvider) { + this(baseUrl, clientHeaders, credentialsProvider, null, null, null); + } + + /** + * Constructs a HttpMcpTransport. + * + * @param baseUrl The base URL of the remote service. + * @param clientHeaders Default HTTP headers to include in every request. + * @param preferredProtocolVersion Preferred MCP protocol version. + * @param httpClient Custom HTTP Client. + * @param executor Optional Executor for handling async requests. + */ + public HttpMcpTransport( + final String baseUrl, + final Map clientHeaders, + final ProtocolVersion preferredProtocolVersion, + final HttpClient httpClient, + final java.util.concurrent.Executor executor) { + this(baseUrl, clientHeaders, null, preferredProtocolVersion, httpClient, executor); + } + + /** + * Primary constructor for HttpMcpTransport. + * + * @param baseUrl The base URL of the remote service. + * @param clientHeaders Default HTTP headers to include in every request. + * @param credentialsProvider Provider for retrieving authorization credentials. + * @param preferredProtocolVersion Preferred MCP protocol version. + * @param httpClient Custom HTTP Client. + * @param executor Optional Executor for handling async requests. + */ + public HttpMcpTransport( + final String baseUrl, + final Map clientHeaders, + final CredentialsProvider credentialsProvider, + final ProtocolVersion preferredProtocolVersion, + final HttpClient httpClient, + final java.util.concurrent.Executor executor) { + final ProtocolVersion version = + preferredProtocolVersion != null + ? preferredProtocolVersion + : ProtocolVersion.VERSION_2025_11_25; + + switch (version) { + case VERSION_2025_11_25: + this.delegate = + new HttpMcpTransportV20251125( + baseUrl, clientHeaders, credentialsProvider, httpClient, executor); + break; + case VERSION_2025_06_18: + this.delegate = + new HttpMcpTransportV20250618( + baseUrl, clientHeaders, credentialsProvider, httpClient, executor); + break; + case VERSION_2025_03_26: + this.delegate = + new HttpMcpTransportV20250326( + baseUrl, clientHeaders, credentialsProvider, httpClient, executor); + break; + case VERSION_2024_11_05: + this.delegate = + new HttpMcpTransportV20241105( + baseUrl, clientHeaders, credentialsProvider, httpClient, executor); + break; + default: + throw new IllegalArgumentException("Unsupported protocol version: " + version); + } + } + + /** Internal constructor for testing purposes. */ + public HttpMcpTransport(final String baseUrl, final HttpClient httpClient) { + this(baseUrl, Map.of(), null, null, httpClient, null); + } + + /** Internal constructor for testing purposes. */ + public HttpMcpTransport( + final String baseUrl, final Map clientHeaders, final HttpClient httpClient) { + this(baseUrl, clientHeaders, null, null, httpClient, null); + } + + HttpMcpTransport( + final String baseUrl, + final Map clientHeaders, + final CredentialsProvider credentialsProvider, + final HttpClient httpClient) { + this(baseUrl, clientHeaders, credentialsProvider, null, httpClient, null); + } + + @Override + public String getBaseUrl() { + return delegate.getBaseUrl(); + } + + @Override + public CompletableFuture listTools( + final String toolsetName, final Map metadata) { + return delegate.listTools(toolsetName, metadata); + } + + @Override + public CompletableFuture invokeTool( + final String toolName, + final Map arguments, + final Map metadata) { + return delegate.invokeTool(toolName, arguments, metadata); + } + + @Override + public void close() { + delegate.close(); + } +} diff --git a/src/main/java/com/google/cloud/mcp/Transport.java b/src/main/java/com/google/cloud/mcp/transport/Transport.java similarity index 97% rename from src/main/java/com/google/cloud/mcp/Transport.java rename to src/main/java/com/google/cloud/mcp/transport/Transport.java index 566eefe..37ac88b 100644 --- a/src/main/java/com/google/cloud/mcp/Transport.java +++ b/src/main/java/com/google/cloud/mcp/transport/Transport.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.transport; import java.util.Map; import java.util.concurrent.CompletableFuture; diff --git a/src/main/java/com/google/cloud/mcp/TransportManifest.java b/src/main/java/com/google/cloud/mcp/transport/TransportManifest.java similarity index 92% rename from src/main/java/com/google/cloud/mcp/TransportManifest.java rename to src/main/java/com/google/cloud/mcp/transport/TransportManifest.java index e294afa..f8a8dac 100644 --- a/src/main/java/com/google/cloud/mcp/TransportManifest.java +++ b/src/main/java/com/google/cloud/mcp/transport/TransportManifest.java @@ -14,8 +14,9 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.transport; +import com.google.cloud.mcp.tool.ToolDefinition; import java.util.Map; /** Represents the raw tools manifest returned by the transport. */ diff --git a/src/main/java/com/google/cloud/mcp/TransportResponse.java b/src/main/java/com/google/cloud/mcp/transport/TransportResponse.java similarity index 97% rename from src/main/java/com/google/cloud/mcp/TransportResponse.java rename to src/main/java/com/google/cloud/mcp/transport/TransportResponse.java index 4532b69..5044af4 100644 --- a/src/main/java/com/google/cloud/mcp/TransportResponse.java +++ b/src/main/java/com/google/cloud/mcp/transport/TransportResponse.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.transport; /** Represents a raw transport response containing status code and response body. */ public final class TransportResponse { diff --git a/src/main/java/com/google/cloud/mcp/transport/v20241105/HttpMcpTransportV20241105.java b/src/main/java/com/google/cloud/mcp/transport/v20241105/HttpMcpTransportV20241105.java new file mode 100644 index 0000000..dbbade0 --- /dev/null +++ b/src/main/java/com/google/cloud/mcp/transport/v20241105/HttpMcpTransportV20241105.java @@ -0,0 +1,130 @@ +/* + * 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.transport.v20241105; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.cloud.mcp.JsonRpc; +import com.google.cloud.mcp.ProtocolVersion; +import com.google.cloud.mcp.auth.CredentialsProvider; +import com.google.cloud.mcp.exception.McpException; +import com.google.cloud.mcp.transport.BaseMcpTransport; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +public final class HttpMcpTransportV20241105 extends BaseMcpTransport { + + public HttpMcpTransportV20241105( + final String baseUrl, + final Map clientHeaders, + final CredentialsProvider credentialsProvider, + final HttpClient httpClient, + final java.util.concurrent.Executor executor) { + super( + baseUrl, + clientHeaders, + credentialsProvider, + ProtocolVersion.VERSION_2024_11_05, + httpClient, + executor); + } + + @Override + protected CompletableFuture performInitialization( + final String authHeader, final Map handshakeHeaders) { + try { + if (this.baseUrl.toLowerCase(java.util.Locale.ROOT).startsWith("http://") + && authHeader != null) { + logger.warning(HTTP_WARNING); + } + JsonRpc.Request initReq = + new JsonRpc.Request( + "initialize", + new JsonRpc.InitializeParams( + ProtocolVersion.VERSION_2024_11_05.getValue(), "mcp-toolbox-sdk-java")); + String body = objectMapper.writeValueAsString(initReq); + HttpRequest.Builder req = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl)) + .POST(HttpRequest.BodyPublishers.ofString(body)); + + handshakeHeaders.forEach(req::setHeader); + applyProtocolHeaders(req); + + return httpClient + .sendAsync(req.build(), HttpResponse.BodyHandlers.ofString()) + .thenCompose( + res -> { + if (res.statusCode() != 200) { + return CompletableFuture.failedFuture( + new McpException("Init failed: " + res.statusCode() + " " + res.body())); + } + try { + JsonNode responseJson = objectMapper.readTree(res.body()); + if (responseJson.has("error")) { + return CompletableFuture.failedFuture( + new McpException("MCP Error: " + responseJson.get("error").toString())); + } + JsonNode result = responseJson.get("result"); + String serverVersion; + if (result != null && result.has("protocolVersion")) { + serverVersion = result.get("protocolVersion").asText(); + } else { + serverVersion = ProtocolVersion.VERSION_2024_11_05.getValue(); + } + + if (!ProtocolVersion.VERSION_2024_11_05.getValue().equals(serverVersion)) { + return CompletableFuture.failedFuture( + new McpException( + "MCP version mismatch: client (" + + ProtocolVersion.VERSION_2024_11_05.getValue() + + ") != server (" + + serverVersion + + ")")); + } + + JsonRpc.Notification notif = + new JsonRpc.Notification("notifications/initialized", Map.of()); + String notifBody = objectMapper.writeValueAsString(notif); + HttpRequest.Builder nReq = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl)) + .POST(HttpRequest.BodyPublishers.ofString(notifBody)); + + handshakeHeaders.forEach(nReq::setHeader); + applyProtocolHeaders(nReq); + + return httpClient + .sendAsync(nReq.build(), HttpResponse.BodyHandlers.ofString()) + .thenAccept(nRes -> {}); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + }); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + } + + @Override + protected void applyProtocolHeaders(final HttpRequest.Builder builder) { + builder.header("Content-Type", "application/json"); + } +} diff --git a/src/main/java/com/google/cloud/mcp/transport/v20250326/HttpMcpTransportV20250326.java b/src/main/java/com/google/cloud/mcp/transport/v20250326/HttpMcpTransportV20250326.java new file mode 100644 index 0000000..37b518e --- /dev/null +++ b/src/main/java/com/google/cloud/mcp/transport/v20250326/HttpMcpTransportV20250326.java @@ -0,0 +1,146 @@ +/* + * 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.transport.v20250326; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.cloud.mcp.JsonRpc; +import com.google.cloud.mcp.ProtocolVersion; +import com.google.cloud.mcp.auth.CredentialsProvider; +import com.google.cloud.mcp.exception.McpException; +import com.google.cloud.mcp.transport.BaseMcpTransport; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +public final class HttpMcpTransportV20250326 extends BaseMcpTransport { + + private volatile String sessionId; + + public HttpMcpTransportV20250326( + final String baseUrl, + final Map clientHeaders, + final CredentialsProvider credentialsProvider, + final HttpClient httpClient, + final java.util.concurrent.Executor executor) { + super( + baseUrl, + clientHeaders, + credentialsProvider, + ProtocolVersion.VERSION_2025_03_26, + httpClient, + executor); + } + + @Override + protected CompletableFuture performInitialization( + final String authHeader, final Map handshakeHeaders) { + try { + if (this.baseUrl.toLowerCase(java.util.Locale.ROOT).startsWith("http://") + && authHeader != null) { + logger.warning(HTTP_WARNING); + } + JsonRpc.Request initReq = + new JsonRpc.Request( + "initialize", + new JsonRpc.InitializeParams( + ProtocolVersion.VERSION_2025_03_26.getValue(), "mcp-toolbox-sdk-java")); + String body = objectMapper.writeValueAsString(initReq); + HttpRequest.Builder req = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl)) + .POST(HttpRequest.BodyPublishers.ofString(body)); + + handshakeHeaders.forEach(req::setHeader); + applyProtocolHeaders(req); + + return httpClient + .sendAsync(req.build(), HttpResponse.BodyHandlers.ofString()) + .thenCompose( + res -> { + if (res.statusCode() != 200) { + return CompletableFuture.failedFuture( + new McpException("Init failed: " + res.statusCode() + " " + res.body())); + } + try { + JsonNode responseJson = objectMapper.readTree(res.body()); + if (responseJson.has("error")) { + return CompletableFuture.failedFuture( + new McpException("MCP Error: " + responseJson.get("error").toString())); + } + JsonNode result = responseJson.get("result"); + String serverVersion; + if (result != null && result.has("protocolVersion")) { + serverVersion = result.get("protocolVersion").asText(); + } else { + serverVersion = ProtocolVersion.VERSION_2025_03_26.getValue(); + } + + if (!ProtocolVersion.VERSION_2025_03_26.getValue().equals(serverVersion)) { + return CompletableFuture.failedFuture( + new McpException( + "MCP version mismatch: client (" + + ProtocolVersion.VERSION_2025_03_26.getValue() + + ") != server (" + + serverVersion + + ")")); + } + + Optional sessionIdOpt = res.headers().firstValue("Mcp-Session-Id"); + if (sessionIdOpt.isEmpty()) { + return CompletableFuture.failedFuture( + new McpException( + "Server did not return a Mcp-Session-Id header during" + + " initialization.")); + } + this.sessionId = sessionIdOpt.get(); + + JsonRpc.Notification notif = + new JsonRpc.Notification("notifications/initialized", Map.of()); + String notifBody = objectMapper.writeValueAsString(notif); + HttpRequest.Builder nReq = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl)) + .POST(HttpRequest.BodyPublishers.ofString(notifBody)); + + handshakeHeaders.forEach(nReq::setHeader); + applyProtocolHeaders(nReq); + + return httpClient + .sendAsync(nReq.build(), HttpResponse.BodyHandlers.ofString()) + .thenAccept(nRes -> {}); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + }); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + } + + @Override + protected void applyProtocolHeaders(final HttpRequest.Builder builder) { + builder.header("Content-Type", "application/json"); + builder.header("Accept", "application/json"); + if (sessionId != null) { + builder.header("Mcp-Session-Id", sessionId); + } + } +} diff --git a/src/main/java/com/google/cloud/mcp/transport/v20250618/HttpMcpTransportV20250618.java b/src/main/java/com/google/cloud/mcp/transport/v20250618/HttpMcpTransportV20250618.java new file mode 100644 index 0000000..7ca28ad --- /dev/null +++ b/src/main/java/com/google/cloud/mcp/transport/v20250618/HttpMcpTransportV20250618.java @@ -0,0 +1,132 @@ +/* + * 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.transport.v20250618; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.cloud.mcp.JsonRpc; +import com.google.cloud.mcp.ProtocolVersion; +import com.google.cloud.mcp.auth.CredentialsProvider; +import com.google.cloud.mcp.exception.McpException; +import com.google.cloud.mcp.transport.BaseMcpTransport; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +public final class HttpMcpTransportV20250618 extends BaseMcpTransport { + + public HttpMcpTransportV20250618( + final String baseUrl, + final Map clientHeaders, + final CredentialsProvider credentialsProvider, + final HttpClient httpClient, + final java.util.concurrent.Executor executor) { + super( + baseUrl, + clientHeaders, + credentialsProvider, + ProtocolVersion.VERSION_2025_06_18, + httpClient, + executor); + } + + @Override + protected CompletableFuture performInitialization( + final String authHeader, final Map handshakeHeaders) { + try { + if (this.baseUrl.toLowerCase(java.util.Locale.ROOT).startsWith("http://") + && authHeader != null) { + logger.warning(HTTP_WARNING); + } + JsonRpc.Request initReq = + new JsonRpc.Request( + "initialize", + new JsonRpc.InitializeParams( + ProtocolVersion.VERSION_2025_06_18.getValue(), "mcp-toolbox-sdk-java")); + String body = objectMapper.writeValueAsString(initReq); + HttpRequest.Builder req = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl)) + .POST(HttpRequest.BodyPublishers.ofString(body)); + + handshakeHeaders.forEach(req::setHeader); + applyProtocolHeaders(req); + + return httpClient + .sendAsync(req.build(), HttpResponse.BodyHandlers.ofString()) + .thenCompose( + res -> { + if (res.statusCode() != 200) { + return CompletableFuture.failedFuture( + new McpException("Init failed: " + res.statusCode() + " " + res.body())); + } + try { + JsonNode responseJson = objectMapper.readTree(res.body()); + if (responseJson.has("error")) { + return CompletableFuture.failedFuture( + new McpException("MCP Error: " + responseJson.get("error").toString())); + } + JsonNode result = responseJson.get("result"); + String serverVersion; + if (result != null && result.has("protocolVersion")) { + serverVersion = result.get("protocolVersion").asText(); + } else { + serverVersion = ProtocolVersion.VERSION_2025_06_18.getValue(); + } + + if (!ProtocolVersion.VERSION_2025_06_18.getValue().equals(serverVersion)) { + return CompletableFuture.failedFuture( + new McpException( + "MCP version mismatch: client (" + + ProtocolVersion.VERSION_2025_06_18.getValue() + + ") != server (" + + serverVersion + + ")")); + } + + JsonRpc.Notification notif = + new JsonRpc.Notification("notifications/initialized", Map.of()); + String notifBody = objectMapper.writeValueAsString(notif); + HttpRequest.Builder nReq = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl)) + .POST(HttpRequest.BodyPublishers.ofString(notifBody)); + + handshakeHeaders.forEach(nReq::setHeader); + applyProtocolHeaders(nReq); + + return httpClient + .sendAsync(nReq.build(), HttpResponse.BodyHandlers.ofString()) + .thenAccept(nRes -> {}); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + }); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + } + + @Override + protected void applyProtocolHeaders(final HttpRequest.Builder builder) { + builder.header("Content-Type", "application/json"); + builder.header("Accept", "application/json"); + builder.header("MCP-Protocol-Version", ProtocolVersion.VERSION_2025_06_18.getValue()); + } +} diff --git a/src/main/java/com/google/cloud/mcp/transport/v20251125/HttpMcpTransportV20251125.java b/src/main/java/com/google/cloud/mcp/transport/v20251125/HttpMcpTransportV20251125.java new file mode 100644 index 0000000..c7e5695 --- /dev/null +++ b/src/main/java/com/google/cloud/mcp/transport/v20251125/HttpMcpTransportV20251125.java @@ -0,0 +1,132 @@ +/* + * 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.transport.v20251125; + +import com.fasterxml.jackson.databind.JsonNode; +import com.google.cloud.mcp.JsonRpc; +import com.google.cloud.mcp.ProtocolVersion; +import com.google.cloud.mcp.auth.CredentialsProvider; +import com.google.cloud.mcp.exception.McpException; +import com.google.cloud.mcp.transport.BaseMcpTransport; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +public final class HttpMcpTransportV20251125 extends BaseMcpTransport { + + public HttpMcpTransportV20251125( + final String baseUrl, + final Map clientHeaders, + final CredentialsProvider credentialsProvider, + final HttpClient httpClient, + final java.util.concurrent.Executor executor) { + super( + baseUrl, + clientHeaders, + credentialsProvider, + ProtocolVersion.VERSION_2025_11_25, + httpClient, + executor); + } + + @Override + protected CompletableFuture performInitialization( + final String authHeader, final Map handshakeHeaders) { + try { + if (this.baseUrl.toLowerCase(java.util.Locale.ROOT).startsWith("http://") + && authHeader != null) { + logger.warning(HTTP_WARNING); + } + JsonRpc.Request initReq = + new JsonRpc.Request( + "initialize", + new JsonRpc.InitializeParams( + ProtocolVersion.VERSION_2025_11_25.getValue(), "mcp-toolbox-sdk-java")); + String body = objectMapper.writeValueAsString(initReq); + HttpRequest.Builder req = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl)) + .POST(HttpRequest.BodyPublishers.ofString(body)); + + handshakeHeaders.forEach(req::setHeader); + applyProtocolHeaders(req); + + return httpClient + .sendAsync(req.build(), HttpResponse.BodyHandlers.ofString()) + .thenCompose( + res -> { + if (res.statusCode() != 200) { + return CompletableFuture.failedFuture( + new McpException("Init failed: " + res.statusCode() + " " + res.body())); + } + try { + JsonNode responseJson = objectMapper.readTree(res.body()); + if (responseJson.has("error")) { + return CompletableFuture.failedFuture( + new McpException("MCP Error: " + responseJson.get("error").toString())); + } + JsonNode result = responseJson.get("result"); + String serverVersion; + if (result != null && result.has("protocolVersion")) { + serverVersion = result.get("protocolVersion").asText(); + } else { + serverVersion = ProtocolVersion.VERSION_2025_11_25.getValue(); + } + + if (!ProtocolVersion.VERSION_2025_11_25.getValue().equals(serverVersion)) { + return CompletableFuture.failedFuture( + new McpException( + "MCP version mismatch: client (" + + ProtocolVersion.VERSION_2025_11_25.getValue() + + ") != server (" + + serverVersion + + ")")); + } + + JsonRpc.Notification notif = + new JsonRpc.Notification("notifications/initialized", Map.of()); + String notifBody = objectMapper.writeValueAsString(notif); + HttpRequest.Builder nReq = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl)) + .POST(HttpRequest.BodyPublishers.ofString(notifBody)); + + handshakeHeaders.forEach(nReq::setHeader); + applyProtocolHeaders(nReq); + + return httpClient + .sendAsync(nReq.build(), HttpResponse.BodyHandlers.ofString()) + .thenAccept(nRes -> {}); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + }); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + } + + @Override + protected void applyProtocolHeaders(final HttpRequest.Builder builder) { + builder.header("Content-Type", "application/json"); + builder.header("Accept", "application/json"); + builder.header("MCP-Protocol-Version", ProtocolVersion.VERSION_2025_11_25.getValue()); + } +} diff --git a/src/test/java/com/google/cloud/mcp/AuthMethodsTest.java b/src/test/java/com/google/cloud/mcp/auth/AuthMethodsTest.java similarity index 99% rename from src/test/java/com/google/cloud/mcp/AuthMethodsTest.java rename to src/test/java/com/google/cloud/mcp/auth/AuthMethodsTest.java index 3cd75ab..66a497f 100644 --- a/src/test/java/com/google/cloud/mcp/AuthMethodsTest.java +++ b/src/test/java/com/google/cloud/mcp/auth/AuthMethodsTest.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.auth; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; diff --git a/src/test/java/com/google/cloud/mcp/HttpMcpToolboxClientTest.java b/src/test/java/com/google/cloud/mcp/client/HttpMcpToolboxClientTest.java similarity index 98% rename from src/test/java/com/google/cloud/mcp/HttpMcpToolboxClientTest.java rename to src/test/java/com/google/cloud/mcp/client/HttpMcpToolboxClientTest.java index a558e6d..9ea1dad 100644 --- a/src/test/java/com/google/cloud/mcp/HttpMcpToolboxClientTest.java +++ b/src/test/java/com/google/cloud/mcp/client/HttpMcpToolboxClientTest.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.client; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; @@ -22,6 +22,8 @@ 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.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import com.sun.net.httpserver.HttpServer; diff --git a/src/test/java/com/google/cloud/mcp/McpToolboxClientBuilderTest.java b/src/test/java/com/google/cloud/mcp/client/McpToolboxClientBuilderTest.java similarity index 94% rename from src/test/java/com/google/cloud/mcp/McpToolboxClientBuilderTest.java rename to src/test/java/com/google/cloud/mcp/client/McpToolboxClientBuilderTest.java index 5de1e1f..b5b1168 100644 --- a/src/test/java/com/google/cloud/mcp/McpToolboxClientBuilderTest.java +++ b/src/test/java/com/google/cloud/mcp/client/McpToolboxClientBuilderTest.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.client; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -23,6 +23,13 @@ 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.ProtocolVersion; +import com.google.cloud.mcp.auth.CredentialsProvider; +import com.google.cloud.mcp.exception.McpException; +import com.google.cloud.mcp.tool.ToolPostProcessor; +import com.google.cloud.mcp.tool.ToolPreProcessor; +import com.google.cloud.mcp.transport.Transport; import java.lang.reflect.Field; import java.lang.reflect.Method; import java.util.Map; diff --git a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplErrorsTest.java b/src/test/java/com/google/cloud/mcp/client/McpToolboxClientImplErrorsTest.java similarity index 97% rename from src/test/java/com/google/cloud/mcp/McpToolboxClientImplErrorsTest.java rename to src/test/java/com/google/cloud/mcp/client/McpToolboxClientImplErrorsTest.java index 36d1686..49732a8 100644 --- a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplErrorsTest.java +++ b/src/test/java/com/google/cloud/mcp/client/McpToolboxClientImplErrorsTest.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.client; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -24,6 +24,10 @@ import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.cloud.mcp.auth.CredentialsProvider; +import com.google.cloud.mcp.tool.ToolDefinition; +import com.google.cloud.mcp.tool.ToolResult; +import com.google.cloud.mcp.transport.HttpMcpTransport; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; diff --git a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplHeadersTest.java b/src/test/java/com/google/cloud/mcp/client/McpToolboxClientImplHeadersTest.java similarity index 92% rename from src/test/java/com/google/cloud/mcp/McpToolboxClientImplHeadersTest.java rename to src/test/java/com/google/cloud/mcp/client/McpToolboxClientImplHeadersTest.java index 72b7031..7dc2aab 100644 --- a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplHeadersTest.java +++ b/src/test/java/com/google/cloud/mcp/client/McpToolboxClientImplHeadersTest.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.client; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; @@ -24,6 +24,10 @@ import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.cloud.mcp.McpToolboxClient; +import com.google.cloud.mcp.auth.CredentialsProvider; +import com.google.cloud.mcp.transport.BaseMcpTransport; +import com.google.cloud.mcp.transport.HttpMcpTransport; import java.lang.reflect.Field; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -66,9 +70,12 @@ void testCustomHeadersPopulatedInAllRequests() throws Exception { Field transportField = McpToolboxClientImpl.class.getDeclaredField("transport"); transportField.setAccessible(true); HttpMcpTransport transport = (HttpMcpTransport) transportField.get(client); - Field httpClientField = HttpMcpTransport.class.getDeclaredField("httpClient"); + Field delegateField = HttpMcpTransport.class.getDeclaredField("delegate"); + delegateField.setAccessible(true); + Object delegate = delegateField.get(transport); + Field httpClientField = BaseMcpTransport.class.getDeclaredField("httpClient"); httpClientField.setAccessible(true); - httpClientField.set(transport, mockHttpClient); + httpClientField.set(delegate, mockHttpClient); HttpResponse initResponse = mock(HttpResponse.class); when(initResponse.statusCode()).thenReturn(200); @@ -150,9 +157,12 @@ void testExtraHeadersOverrideAndAuthPriority() throws Exception { Field transportField = McpToolboxClientImpl.class.getDeclaredField("transport"); transportField.setAccessible(true); HttpMcpTransport transport = (HttpMcpTransport) transportField.get(client); - Field httpClientField = HttpMcpTransport.class.getDeclaredField("httpClient"); + Field delegateField = HttpMcpTransport.class.getDeclaredField("delegate"); + delegateField.setAccessible(true); + Object delegate = delegateField.get(transport); + Field httpClientField = BaseMcpTransport.class.getDeclaredField("httpClient"); httpClientField.setAccessible(true); - httpClientField.set(transport, mockHttpClient); + httpClientField.set(delegate, mockHttpClient); HttpResponse initResponse = mock(HttpResponse.class); when(initResponse.statusCode()).thenReturn(200); @@ -223,9 +233,12 @@ void testNoDuplicateHeaders() throws Exception { Field transportField = McpToolboxClientImpl.class.getDeclaredField("transport"); transportField.setAccessible(true); HttpMcpTransport transport = (HttpMcpTransport) transportField.get(client); - Field httpClientField = HttpMcpTransport.class.getDeclaredField("httpClient"); + Field delegateField = HttpMcpTransport.class.getDeclaredField("delegate"); + delegateField.setAccessible(true); + Object delegate = delegateField.get(transport); + Field httpClientField = BaseMcpTransport.class.getDeclaredField("httpClient"); httpClientField.setAccessible(true); - httpClientField.set(transport, mockHttpClient); + httpClientField.set(delegate, mockHttpClient); HttpResponse initResponse = mock(HttpResponse.class); when(initResponse.statusCode()).thenReturn(200); diff --git a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplJsonRpcTest.java b/src/test/java/com/google/cloud/mcp/client/McpToolboxClientImplJsonRpcTest.java similarity index 98% rename from src/test/java/com/google/cloud/mcp/McpToolboxClientImplJsonRpcTest.java rename to src/test/java/com/google/cloud/mcp/client/McpToolboxClientImplJsonRpcTest.java index 3a9a99e..2bb1db9 100644 --- a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplJsonRpcTest.java +++ b/src/test/java/com/google/cloud/mcp/client/McpToolboxClientImplJsonRpcTest.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.client; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -26,6 +26,11 @@ import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.cloud.mcp.JsonRpc; +import com.google.cloud.mcp.auth.CredentialsProvider; +import com.google.cloud.mcp.tool.ToolDefinition; +import com.google.cloud.mcp.tool.ToolResult; +import com.google.cloud.mcp.transport.HttpMcpTransport; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; diff --git a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplTest.java b/src/test/java/com/google/cloud/mcp/client/McpToolboxClientImplTest.java similarity index 95% rename from src/test/java/com/google/cloud/mcp/McpToolboxClientImplTest.java rename to src/test/java/com/google/cloud/mcp/client/McpToolboxClientImplTest.java index 075fbf0..2ff4387 100644 --- a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplTest.java +++ b/src/test/java/com/google/cloud/mcp/client/McpToolboxClientImplTest.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.client; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -31,6 +31,20 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.google.auth.oauth2.GoogleCredentials; +import com.google.cloud.mcp.JsonRpc; +import com.google.cloud.mcp.McpToolboxClient; +import com.google.cloud.mcp.auth.AuthTokenGetter; +import com.google.cloud.mcp.auth.CredentialsProvider; +import com.google.cloud.mcp.tool.Tool; +import com.google.cloud.mcp.tool.ToolDefinition; +import com.google.cloud.mcp.tool.ToolPostProcessor; +import com.google.cloud.mcp.tool.ToolPreProcessor; +import com.google.cloud.mcp.tool.ToolResult; +import com.google.cloud.mcp.transport.BaseMcpTransport; +import com.google.cloud.mcp.transport.HttpMcpTransport; +import com.google.cloud.mcp.transport.Transport; +import com.google.cloud.mcp.transport.TransportManifest; +import com.google.cloud.mcp.transport.TransportResponse; import java.io.IOException; import java.lang.reflect.Field; import java.lang.reflect.Method; @@ -514,10 +528,12 @@ void testLoadToolset_withInvalidUriThrowsException() { @Test void testInvokeTool_withInvalidUriThrowsException() throws Exception { HttpMcpTransport transport = new HttpMcpTransport("http://invalid uri", mockHttpClient); - Field initFutureField = HttpMcpTransport.class.getDeclaredField("initFuture"); + Field delegateField = HttpMcpTransport.class.getDeclaredField("delegate"); + delegateField.setAccessible(true); + Object delegate = delegateField.get(transport); + Field initFutureField = BaseMcpTransport.class.getDeclaredField("initFuture"); initFutureField.setAccessible(true); - initFutureField.set( - transport, CompletableFuture.completedFuture(null)); // bypass initialization + initFutureField.set(delegate, CompletableFuture.completedFuture(null)); // bypass initialization McpToolboxClientImpl badClient = new McpToolboxClientImpl(transport, java.util.Collections.emptyMap(), null); @@ -594,11 +610,15 @@ void testEnsureInitialized_withNullAuthHeader() throws Exception { .thenReturn(CompletableFuture.completedFuture(initResponse)) .thenReturn(CompletableFuture.completedFuture(notifResponse)); - Method initMethod = HttpMcpTransport.class.getDeclaredMethod("ensureInitialized", Map.class); + Field delegateField = HttpMcpTransport.class.getDeclaredField("delegate"); + delegateField.setAccessible(true); + Object delegate = delegateField.get(transport); + + Method initMethod = BaseMcpTransport.class.getDeclaredMethod("ensureInitialized", Map.class); initMethod.setAccessible(true); CompletableFuture future = - (CompletableFuture) initMethod.invoke(transport, java.util.Collections.emptyMap()); + (CompletableFuture) initMethod.invoke(delegate, java.util.Collections.emptyMap()); future.join(); // should complete and NOT set Authorization header ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); @@ -782,9 +802,12 @@ void testListTools_withInvalidToolsetNameThrows() throws Exception { HttpMcpTransport transport = new HttpMcpTransport("http://localhost:8080", mockHttpClient); // Force transport to be initialized first - Field initFutureField = HttpMcpTransport.class.getDeclaredField("initFuture"); + Field delegateField = HttpMcpTransport.class.getDeclaredField("delegate"); + delegateField.setAccessible(true); + Object delegate = delegateField.get(transport); + Field initFutureField = BaseMcpTransport.class.getDeclaredField("initFuture"); initFutureField.setAccessible(true); - initFutureField.set(transport, CompletableFuture.completedFuture(null)); + initFutureField.set(delegate, CompletableFuture.completedFuture(null)); CompletableFuture future = transport.listTools("invalid path with spaces \\", java.util.Collections.emptyMap()); @@ -805,9 +828,12 @@ void testEnsureInitialized_withNotificationSerializationFailure() throws Excepti when(mockMapper.writeValueAsString(any(JsonRpc.Notification.class))) .thenThrow(new RuntimeException("Simulated notification serialization failure")); - Field mapperField = HttpMcpTransport.class.getDeclaredField("objectMapper"); + Field delegateField = HttpMcpTransport.class.getDeclaredField("delegate"); + delegateField.setAccessible(true); + Object delegate = delegateField.get(transport); + Field mapperField = BaseMcpTransport.class.getDeclaredField("objectMapper"); mapperField.setAccessible(true); - mapperField.set(transport, mockMapper); + mapperField.set(delegate, mockMapper); HttpResponse initResponse = mock(HttpResponse.class); when(initResponse.statusCode()).thenReturn(200); @@ -816,11 +842,11 @@ void testEnsureInitialized_withNotificationSerializationFailure() throws Excepti when(mockHttpClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) .thenReturn(CompletableFuture.completedFuture(initResponse)); - Method initMethod = HttpMcpTransport.class.getDeclaredMethod("ensureInitialized", Map.class); + Method initMethod = BaseMcpTransport.class.getDeclaredMethod("ensureInitialized", Map.class); initMethod.setAccessible(true); CompletableFuture future = - (CompletableFuture) initMethod.invoke(transport, java.util.Collections.emptyMap()); + (CompletableFuture) initMethod.invoke(delegate, java.util.Collections.emptyMap()); java.util.concurrent.ExecutionException ex = org.junit.jupiter.api.Assertions.assertThrows( diff --git a/src/test/java/com/google/cloud/mcp/e2e/McpToolboxClientTest.java b/src/test/java/com/google/cloud/mcp/e2e/McpToolboxClientTest.java index c2b61e9..77f9823 100644 --- a/src/test/java/com/google/cloud/mcp/e2e/McpToolboxClientTest.java +++ b/src/test/java/com/google/cloud/mcp/e2e/McpToolboxClientTest.java @@ -21,9 +21,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import com.google.cloud.mcp.McpToolboxClient; -import com.google.cloud.mcp.Tool; -import com.google.cloud.mcp.ToolDefinition; -import com.google.cloud.mcp.ToolResult; +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; diff --git a/src/test/java/com/google/cloud/mcp/ToolTest.java b/src/test/java/com/google/cloud/mcp/tool/ToolTest.java similarity index 99% rename from src/test/java/com/google/cloud/mcp/ToolTest.java rename to src/test/java/com/google/cloud/mcp/tool/ToolTest.java index 4c3682a..9ba2c0d 100644 --- a/src/test/java/com/google/cloud/mcp/ToolTest.java +++ b/src/test/java/com/google/cloud/mcp/tool/ToolTest.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.tool; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertSame; @@ -28,6 +28,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.google.cloud.mcp.McpToolboxClient; +import com.google.cloud.mcp.auth.ResolvedAuth; import java.util.ArrayList; import java.util.HashMap; import java.util.List; diff --git a/src/test/java/com/google/cloud/mcp/ToolValidationTest.java b/src/test/java/com/google/cloud/mcp/tool/ToolValidationTest.java similarity index 99% rename from src/test/java/com/google/cloud/mcp/ToolValidationTest.java rename to src/test/java/com/google/cloud/mcp/tool/ToolValidationTest.java index bef9d1a..e645a73 100644 --- a/src/test/java/com/google/cloud/mcp/ToolValidationTest.java +++ b/src/test/java/com/google/cloud/mcp/tool/ToolValidationTest.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.tool; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -27,6 +27,7 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.google.cloud.mcp.McpToolboxClient; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; diff --git a/src/test/java/com/google/cloud/mcp/HttpMcpTransportTest.java b/src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportTest.java similarity index 97% rename from src/test/java/com/google/cloud/mcp/HttpMcpTransportTest.java rename to src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportTest.java index 1787e89..d96aa76 100644 --- a/src/test/java/com/google/cloud/mcp/HttpMcpTransportTest.java +++ b/src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportTest.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.cloud.mcp; +package com.google.cloud.mcp.transport; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; @@ -26,6 +26,10 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.google.cloud.mcp.ProtocolVersion; +import com.google.cloud.mcp.auth.CredentialsProvider; +import com.google.cloud.mcp.exception.McpException; +import com.google.cloud.mcp.tool.ToolDefinition; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; @@ -253,9 +257,13 @@ void testConstructor_WithCustomExecutorConfiguresHttpClient() throws Exception { null, customExecutor); - java.lang.reflect.Field httpClientField = HttpMcpTransport.class.getDeclaredField("httpClient"); + java.lang.reflect.Field delegateField = HttpMcpTransport.class.getDeclaredField("delegate"); + delegateField.setAccessible(true); + Object delegate = delegateField.get(transport); + + java.lang.reflect.Field httpClientField = BaseMcpTransport.class.getDeclaredField("httpClient"); httpClientField.setAccessible(true); - java.net.http.HttpClient httpClient = (java.net.http.HttpClient) httpClientField.get(transport); + java.net.http.HttpClient httpClient = (java.net.http.HttpClient) httpClientField.get(delegate); assertNotNull(httpClient); Object internalExecutor = null; @@ -319,7 +327,7 @@ void testListTools_WithHttpUrlAndMetadata_LogsWarning() throws Exception { .thenReturn(CompletableFuture.completedFuture(mockListResponse)); java.util.logging.Logger transportLogger = - java.util.logging.Logger.getLogger(HttpMcpTransport.class.getName()); + java.util.logging.Logger.getLogger(BaseMcpTransport.class.getName()); java.util.List logRecords = new java.util.ArrayList<>(); java.util.logging.Handler logHandler = new java.util.logging.Handler() { From 90bfc0b627fa175095cbb5c0591a0c1e1352cb36 Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Fri, 26 Jun 2026 23:30:58 +0530 Subject: [PATCH 2/3] Implement Stateless MCP (SEP-2575) in Java SDK - Register stateless protocol version 2026-06-18 - Add _meta parameters for inline protocol negotiation metadata - Support MCP-Protocol-Version HTTP header - Bypass connection handshakes in HttpMcpTransportV20260618 - Implement dynamic client-side fallback/retry protocol negotiation TAG=agy CONV=2e1a2106-f882-4b08-92df-a27ba37c6fae --- .../java/com/google/cloud/mcp/JsonRpc.java | 3 + .../com/google/cloud/mcp/ProtocolVersion.java | 3 + .../mcp/client/McpToolboxClientImpl.java | 70 ++- .../McpProtocolNegotiationException.java | 43 ++ .../cloud/mcp/transport/BaseMcpTransport.java | 84 ++- .../cloud/mcp/transport/HttpMcpTransport.java | 30 +- .../v20260618/HttpMcpTransportV20260618.java | 78 +++ .../HttpMcpTransportStatelessTest.java | 313 ++++++++++++ .../mcp/transport/HttpMcpTransportTest.java | 481 +++++++++++++----- 9 files changed, 973 insertions(+), 132 deletions(-) create mode 100644 src/main/java/com/google/cloud/mcp/exception/McpProtocolNegotiationException.java create mode 100644 src/main/java/com/google/cloud/mcp/transport/v20260618/HttpMcpTransportV20260618.java create mode 100644 src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportStatelessTest.java diff --git a/src/main/java/com/google/cloud/mcp/JsonRpc.java b/src/main/java/com/google/cloud/mcp/JsonRpc.java index 7bc7501..a4cd1ef 100644 --- a/src/main/java/com/google/cloud/mcp/JsonRpc.java +++ b/src/main/java/com/google/cloud/mcp/JsonRpc.java @@ -16,6 +16,7 @@ package com.google.cloud.mcp; +import com.fasterxml.jackson.annotation.JsonInclude; import java.util.Map; import java.util.UUID; @@ -44,9 +45,11 @@ public Notification(final String method, final Object params) { } } + @JsonInclude(JsonInclude.Include.NON_NULL) public static class CallToolParams { public String name; public Map arguments; + public Map _meta; public CallToolParams(final String name, final Map arguments) { this.name = name; diff --git a/src/main/java/com/google/cloud/mcp/ProtocolVersion.java b/src/main/java/com/google/cloud/mcp/ProtocolVersion.java index ab661e5..f60869b 100644 --- a/src/main/java/com/google/cloud/mcp/ProtocolVersion.java +++ b/src/main/java/com/google/cloud/mcp/ProtocolVersion.java @@ -18,6 +18,9 @@ /** Supported protocol versions for the Model Context Protocol. */ public enum ProtocolVersion { + /** Protocol version 2026-06-18. */ + VERSION_2026_06_18("2026-06-18", true, true, false), + /** Protocol version 2025-11-25. */ VERSION_2025_11_25("2025-11-25", true, true, false), diff --git a/src/main/java/com/google/cloud/mcp/client/McpToolboxClientImpl.java b/src/main/java/com/google/cloud/mcp/client/McpToolboxClientImpl.java index d8ebdd2..603d255 100644 --- a/src/main/java/com/google/cloud/mcp/client/McpToolboxClientImpl.java +++ b/src/main/java/com/google/cloud/mcp/client/McpToolboxClientImpl.java @@ -234,12 +234,14 @@ public CompletableFuture> listTools() { @Override public CompletableFuture> loadToolset(final String toolsetName) { - return getMergedMetadata(java.util.Collections.emptyMap()) - .thenCompose( - mergedMetadata -> - transport - .listTools(toolsetName, mergedMetadata) - .thenApply(TransportManifest::getTools)); + return executeWithFallback( + () -> + getMergedMetadata(java.util.Collections.emptyMap()) + .thenCompose( + mergedMetadata -> + transport + .listTools(toolsetName, mergedMetadata) + .thenApply(TransportManifest::getTools))); } @Override @@ -345,12 +347,14 @@ public CompletableFuture invokeTool( && !extraHeaders.isEmpty()) { LOGGER.warning(HTTP_WARNING); } - return getMergedMetadata(extraHeaders) - .thenCompose( - mergedMetadata -> - transport - .invokeTool(toolName, arguments, mergedMetadata) - .thenApply(res -> handleInvokeResponse(res, toolName))); + return executeWithFallback( + () -> + getMergedMetadata(extraHeaders) + .thenCompose( + mergedMetadata -> + transport + .invokeTool(toolName, arguments, mergedMetadata) + .thenApply(res -> handleInvokeResponse(res, toolName)))); } private CompletableFuture getAuthorizationHeader() { @@ -400,4 +404,46 @@ private ToolResult handleInvokeResponse(final TransportResponse response, final return new ToolResult(java.util.List.of(new ToolResult.Content("text", body)), false); } } + + private CompletableFuture executeWithFallback( + java.util.function.Supplier> action) { + CompletableFuture future; + try { + future = action.get(); + } catch (Exception e) { + future = CompletableFuture.failedFuture(e); + } + return future + .handle( + (result, ex) -> { + if (ex != null) { + Throwable cause = + (ex instanceof java.util.concurrent.CompletionException) ? ex.getCause() : ex; + if (cause + instanceof + com.google.cloud.mcp.exception.McpProtocolNegotiationException negEx) { + if (this.transport instanceof HttpMcpTransport httpTransport) { + com.google.cloud.mcp.ProtocolVersion fallbackVer = + com.google.cloud.mcp.ProtocolVersion.fromString( + negEx.getNegotiatedVersion()); + if (fallbackVer != null) { + LOGGER.warning( + "Protocol fallback required. Switching to version " + + fallbackVer.getValue()); + httpTransport.switchVersion(fallbackVer); + + try { + return action.get(); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + } + } + } + return CompletableFuture.failedFuture(ex); + } + return CompletableFuture.completedFuture(result); + }) + .thenCompose(f -> f); + } } diff --git a/src/main/java/com/google/cloud/mcp/exception/McpProtocolNegotiationException.java b/src/main/java/com/google/cloud/mcp/exception/McpProtocolNegotiationException.java new file mode 100644 index 0000000..c449868 --- /dev/null +++ b/src/main/java/com/google/cloud/mcp/exception/McpProtocolNegotiationException.java @@ -0,0 +1,43 @@ +/* + * 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.exception; + +/** Exception thrown when the MCP Server requests a protocol version fallback/negotiation. */ +public final class McpProtocolNegotiationException extends McpException { + + private final String negotiatedVersion; + + /** + * Constructs a new McpProtocolNegotiationException. + * + * @param message The detail message. + * @param negotiatedVersion The negotiated protocol version (e.g. "2025-11-25"). + */ + public McpProtocolNegotiationException(String message, String negotiatedVersion) { + super(message); + this.negotiatedVersion = negotiatedVersion; + } + + /** + * Gets the negotiated protocol version string. + * + * @return The protocol version string. + */ + public String getNegotiatedVersion() { + return negotiatedVersion; + } +} diff --git a/src/main/java/com/google/cloud/mcp/transport/BaseMcpTransport.java b/src/main/java/com/google/cloud/mcp/transport/BaseMcpTransport.java index 6a60297..f9bd0c2 100644 --- a/src/main/java/com/google/cloud/mcp/transport/BaseMcpTransport.java +++ b/src/main/java/com/google/cloud/mcp/transport/BaseMcpTransport.java @@ -192,6 +192,10 @@ protected abstract CompletableFuture performInitialization( protected abstract void applyProtocolHeaders(final HttpRequest.Builder builder); + protected Object modifyRequestParams(final String method, final Object params) { + return params; + } + @Override public final CompletableFuture listTools( final String toolsetName, final Map metadata) { @@ -206,7 +210,8 @@ public final CompletableFuture listTools( String path = toolsetName != null && !toolsetName.isEmpty() ? "/" + toolsetName : ""; String url = baseUrl + path; try { - JsonRpc.Request listReq = new JsonRpc.Request("tools/list", Map.of()); + Object finalParams = modifyRequestParams("tools/list", Map.of()); + JsonRpc.Request listReq = new JsonRpc.Request("tools/list", finalParams); String body = objectMapper.writeValueAsString(listReq); HttpRequest.Builder req = HttpRequest.newBuilder() @@ -238,9 +243,10 @@ public final CompletableFuture invokeTool( .thenCompose( mergedHeaders -> { try { - JsonRpc.Request invokeReq = - new JsonRpc.Request( + Object finalParams = + modifyRequestParams( "tools/call", new JsonRpc.CallToolParams(toolName, arguments)); + JsonRpc.Request invokeReq = new JsonRpc.Request("tools/call", finalParams); String requestBody = objectMapper.writeValueAsString(invokeReq); HttpRequest.Builder requestBuilder = @@ -253,7 +259,11 @@ public final CompletableFuture invokeTool( return httpClient .sendAsync(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()) - .thenApply(res -> new TransportResponse(res.statusCode(), res.body())); + .thenApply( + res -> { + checkResponseForNegotiationError(res); + return new TransportResponse(res.statusCode(), res.body()); + }); } catch (Exception e) { return CompletableFuture.failedFuture(e); } @@ -266,6 +276,7 @@ public void close() { } private TransportManifest handleListToolsResponse(final HttpResponse response) { + checkResponseForNegotiationError(response); if (response.statusCode() != 200) { throw new RuntimeException( "Failed to list tools. Status: " + response.statusCode() + " " + response.body()); @@ -273,7 +284,8 @@ private TransportManifest handleListToolsResponse(final HttpResponse res try { JsonNode root = objectMapper.readTree(response.body()); if (root.has("error")) { - throw new RuntimeException("MCP Error: " + root.get("error").toString()); + throw new com.google.cloud.mcp.exception.McpException( + "MCP Error: " + root.get("error").toString()); } JsonNode result = root.get("result"); JsonNode toolsNode = result.get("tools"); @@ -356,8 +368,70 @@ private TransportManifest handleListToolsResponse(final HttpResponse res } } return new TransportManifest(toolsMap); + } catch (com.google.cloud.mcp.exception.McpException e) { + throw e; } catch (Exception e) { throw new RuntimeException(e); } } + + private void checkResponseForNegotiationError(final HttpResponse response) { + if (response == null || response.body() == null || response.body().isEmpty()) { + return; + } + try { + JsonNode root = objectMapper.readTree(response.body()); + if (root.has("error")) { + JsonNode errorNode = root.get("error"); + if (errorNode.has("code")) { + int code = errorNode.get("code").asInt(); + if (code == -32004 || code == -32001) { + JsonNode dataNode = errorNode.get("data"); + if (dataNode != null && dataNode.has("supported")) { + JsonNode supportedNode = dataNode.get("supported"); + if (supportedNode.isArray()) { + java.util.List serverSupported = new java.util.ArrayList<>(); + for (JsonNode versionNode : supportedNode) { + serverSupported.add(versionNode.asText()); + } + + String negotiated = findHighestCommonVersion(serverSupported); + if (negotiated != null) { + throw new com.google.cloud.mcp.exception.McpProtocolNegotiationException( + "Protocol version fallback requested by server to: " + negotiated, + negotiated); + } else { + throw new com.google.cloud.mcp.exception.McpException( + "No mutually supported protocol version. Client supports: [2026-06-18, 2025-11-25, 2025-06-18, 2025-03-26, 2024-11-05], Server supports: " + + serverSupported); + } + } + } + } + } + } + } catch (com.google.cloud.mcp.exception.McpProtocolNegotiationException e) { + throw e; + } catch (com.google.cloud.mcp.exception.McpException e) { + throw e; + } catch (Exception e) { + // Ignore JSON parsing exceptions here, they will be caught by caller + } + } + + private String findHighestCommonVersion(java.util.List serverSupported) { + java.util.List clientPreference = + java.util.List.of( + ProtocolVersion.VERSION_2026_06_18.getValue(), + ProtocolVersion.VERSION_2025_11_25.getValue(), + ProtocolVersion.VERSION_2025_06_18.getValue(), + ProtocolVersion.VERSION_2025_03_26.getValue(), + ProtocolVersion.VERSION_2024_11_05.getValue()); + for (String preferred : clientPreference) { + if (serverSupported.contains(preferred)) { + return preferred; + } + } + return null; + } } diff --git a/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransport.java b/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransport.java index d5833fc..8243a37 100644 --- a/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransport.java +++ b/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransport.java @@ -22,6 +22,7 @@ import com.google.cloud.mcp.transport.v20250326.HttpMcpTransportV20250326; import com.google.cloud.mcp.transport.v20250618.HttpMcpTransportV20250618; import com.google.cloud.mcp.transport.v20251125.HttpMcpTransportV20251125; +import com.google.cloud.mcp.transport.v20260618.HttpMcpTransportV20260618; import java.net.http.HttpClient; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -29,7 +30,12 @@ /** Default HTTP transport implementation routing requests to version-specific handlers. */ public final class HttpMcpTransport implements Transport { - private final Transport delegate; + private final String baseUrl; + private final Map clientHeaders; + private final CredentialsProvider credentialsProvider; + private final HttpClient httpClient; + private final java.util.concurrent.Executor executor; + private volatile Transport delegate; /** * Constructs a new HttpMcpTransport with a base URL. @@ -99,12 +105,34 @@ public HttpMcpTransport( final ProtocolVersion preferredProtocolVersion, final HttpClient httpClient, final java.util.concurrent.Executor executor) { + this.baseUrl = baseUrl; + this.clientHeaders = clientHeaders; + this.credentialsProvider = credentialsProvider; + this.httpClient = httpClient; + this.executor = executor; + final ProtocolVersion version = preferredProtocolVersion != null ? preferredProtocolVersion : ProtocolVersion.VERSION_2025_11_25; + switchVersion(version); + } + + public synchronized void switchVersion(final ProtocolVersion version) { + if (this.delegate != null) { + try { + this.delegate.close(); + } catch (Exception e) { + // ignore + } + } switch (version) { + case VERSION_2026_06_18: + this.delegate = + new HttpMcpTransportV20260618( + baseUrl, clientHeaders, credentialsProvider, httpClient, executor); + break; case VERSION_2025_11_25: this.delegate = new HttpMcpTransportV20251125( diff --git a/src/main/java/com/google/cloud/mcp/transport/v20260618/HttpMcpTransportV20260618.java b/src/main/java/com/google/cloud/mcp/transport/v20260618/HttpMcpTransportV20260618.java new file mode 100644 index 0000000..61914d9 --- /dev/null +++ b/src/main/java/com/google/cloud/mcp/transport/v20260618/HttpMcpTransportV20260618.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.transport.v20260618; + +import com.google.cloud.mcp.JsonRpc; +import com.google.cloud.mcp.ProtocolVersion; +import com.google.cloud.mcp.auth.CredentialsProvider; +import com.google.cloud.mcp.transport.BaseMcpTransport; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +public final class HttpMcpTransportV20260618 extends BaseMcpTransport { + + public HttpMcpTransportV20260618( + final String baseUrl, + final Map clientHeaders, + final CredentialsProvider credentialsProvider, + final HttpClient httpClient, + final java.util.concurrent.Executor executor) { + super( + baseUrl, + clientHeaders, + credentialsProvider, + ProtocolVersion.VERSION_2026_06_18, + httpClient, + executor); + } + + @Override + protected CompletableFuture performInitialization( + final String authHeader, final Map handshakeHeaders) { + return CompletableFuture.completedFuture(null); + } + + @Override + protected void applyProtocolHeaders(final HttpRequest.Builder builder) { + builder.header("Content-Type", "application/json"); + builder.header("Accept", "application/json"); + builder.header("MCP-Protocol-Version", ProtocolVersion.VERSION_2026_06_18.getValue()); + } + + @Override + protected Object modifyRequestParams(final String method, final Object params) { + Map meta = getRequestMeta(); + if ("tools/list".equals(method)) { + return Map.of("_meta", meta); + } else if ("tools/call".equals(method) && params instanceof JsonRpc.CallToolParams) { + JsonRpc.CallToolParams callParams = (JsonRpc.CallToolParams) params; + callParams._meta = meta; + return callParams; + } + return params; + } + + private Map getRequestMeta() { + return Map.of( + "io.modelcontextprotocol/protocolVersion", ProtocolVersion.VERSION_2026_06_18.getValue(), + "io.modelcontextprotocol/clientInfo", + Map.of("name", "mcp-toolbox-sdk-java", "version", "1.0.0"), + "io.modelcontextprotocol/clientCapabilities", Map.of()); + } +} diff --git a/src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportStatelessTest.java b/src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportStatelessTest.java new file mode 100644 index 0000000..a7dd764 --- /dev/null +++ b/src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportStatelessTest.java @@ -0,0 +1,313 @@ +/* + * 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.transport; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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 static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.cloud.mcp.ProtocolVersion; +import com.google.cloud.mcp.client.McpToolboxClientImpl; +import com.google.cloud.mcp.exception.McpProtocolNegotiationException; +import com.google.cloud.mcp.tool.ToolDefinition; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Collections; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.mockito.ArgumentCaptor; + +@Timeout(value = 5, unit = java.util.concurrent.TimeUnit.SECONDS) +class HttpMcpTransportStatelessTest { + + private HttpClient mockClient; + private HttpMcpTransport transport; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + mockClient = mock(HttpClient.class); + // Explicitly configure stateless protocol version + transport = + new HttpMcpTransport( + "https://test-mcp-service.com", + Collections.emptyMap(), + null, + ProtocolVersion.VERSION_2026_06_18, + mockClient, + null); + } + + @Test + @SuppressWarnings("unchecked") + void testListTools_BypassesHandshake_InjectsMetadata() throws Exception { + HttpResponse mockListResponse = mock(HttpResponse.class); + when(mockListResponse.statusCode()).thenReturn(200); + when(mockListResponse.body()) + .thenReturn("{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"tools\":[]}}"); + + when(mockClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockListResponse)); + + CompletableFuture future = transport.listTools("", Collections.emptyMap()); + TransportManifest manifest = future.get(); + + assertNotNull(manifest); + + // Verify only 1 HTTP request was made (no initialize handshake) + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(mockClient, times(1)) + .sendAsync(requestCaptor.capture(), any(HttpResponse.BodyHandler.class)); + + HttpRequest request = requestCaptor.getValue(); + assertEquals("https://test-mcp-service.com", request.uri().toString()); + assertEquals("POST", request.method()); + assertEquals("2026-06-18", request.headers().firstValue("MCP-Protocol-Version").orElse("")); + + // Verify metadata was serialized into params + // HttpRequest body is not easily readable directly from the object without custom publisher + // mocks, + // but the test confirms it executed successfully. + } + + @Test + @SuppressWarnings("unchecked") + void testListTools_ReceivesNegotiationError_ThrowsException() throws Exception { + HttpResponse mockListResponse = mock(HttpResponse.class); + when(mockListResponse.statusCode()).thenReturn(200); + // Server rejects 2026-06-18 and returns code -32004 with supported version 2025-11-25 + when(mockListResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"error\":{\"code\":-32004,\"message\":\"Unsupported" + + " protocol version\",\"data\":{\"supported\":[\"2025-11-25\"]}}}"); + + when(mockClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockListResponse)); + + CompletableFuture future = transport.listTools("", Collections.emptyMap()); + ExecutionException ex = assertThrows(ExecutionException.class, future::get); + Throwable cause = ex.getCause(); + if (cause instanceof java.util.concurrent.CompletionException) { + cause = cause.getCause(); + } + assertTrue(cause instanceof McpProtocolNegotiationException); + McpProtocolNegotiationException negEx = (McpProtocolNegotiationException) cause; + assertEquals("2025-11-25", negEx.getNegotiatedVersion()); + } + + @Test + @SuppressWarnings("unchecked") + void testClientIntegration_NegotiatesAndRetriesWithFallback() throws Exception { + // We wrap our stateless transport in a client wrapper + McpToolboxClientImpl client = new McpToolboxClientImpl(transport); + + // Call sequence: + // 1. Client calls listTools (stateless, tools/list) -> Server returns error -32004 (supported: + // ["2025-11-25"]) + // 2. Client catches it, switches transport to VERSION_2025_11_25 + // 3. Client retries: + // a. Performs stateful initialize -> Server returns 200 + // b. Performs stateful notifications/initialized -> Server returns 200 + // c. Performs tools/list -> Server returns tools list 200 + + HttpResponse mockListResponseError = mock(HttpResponse.class); + when(mockListResponseError.statusCode()).thenReturn(200); + when(mockListResponseError.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"error\":{\"code\":-32004,\"message\":\"Unsupported" + + " protocol version\",\"data\":{\"supported\":[\"2025-11-25\"]}}}"); + + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(200); + when(mockInitResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"2025-11-25\"}}"); + + HttpResponse mockInitializedResponse = mock(HttpResponse.class); + when(mockInitializedResponse.statusCode()).thenReturn(200); + when(mockInitializedResponse.body()).thenReturn(""); + + HttpResponse mockListResponseSuccess = mock(HttpResponse.class); + when(mockListResponseSuccess.statusCode()).thenReturn(200); + when(mockListResponseSuccess.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"2\",\"result\":{\"tools\":[{\"name\":\"fallback-tool\",\"description\":\"A" + + " fallback tool\",\"inputSchema\":{\"type\":\"object\"}}]}}"); + + when(mockClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockListResponseError)) // first call fails + .thenReturn(CompletableFuture.completedFuture(mockInitResponse)) // fallback init + .thenReturn( + CompletableFuture.completedFuture(mockInitializedResponse)) // fallback initialized + .thenReturn(CompletableFuture.completedFuture(mockListResponseSuccess)); // fallback list + + Map tools = client.listTools().get(); + + assertNotNull(tools); + assertEquals(1, tools.size()); + assertTrue(tools.containsKey("fallback-tool")); + + // Verify 4 calls to sendAsync in total + verify(mockClient, times(4)) + .sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)); + } + + @Test + @SuppressWarnings("unchecked") + void testInvokeTool_BypassesHandshake_InjectsMetadata() throws Exception { + HttpResponse mockInvokeResponse = mock(HttpResponse.class); + when(mockInvokeResponse.statusCode()).thenReturn(200); + when(mockInvokeResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"success\"}]}}"); + + when(mockClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockInvokeResponse)); + + CompletableFuture future = + transport.invokeTool("test-tool", Map.of("p1", "v1"), Collections.emptyMap()); + TransportResponse response = future.get(); + + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + + // Verify only 1 HTTP request was made (no initialize handshake) + ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); + verify(mockClient, times(1)) + .sendAsync(requestCaptor.capture(), any(HttpResponse.BodyHandler.class)); + + HttpRequest request = requestCaptor.getValue(); + assertEquals("https://test-mcp-service.com", request.uri().toString()); + assertEquals("POST", request.method()); + assertEquals("2026-06-18", request.headers().firstValue("MCP-Protocol-Version").orElse("")); + } + + @Test + @SuppressWarnings("unchecked") + void testListTools_ErrorResponseNoCode_ThrowsGenericException() throws Exception { + HttpResponse mockListResponse = mock(HttpResponse.class); + when(mockListResponse.statusCode()).thenReturn(200); + when(mockListResponse.body()) + .thenReturn("{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"error\":{\"message\":\"Some error\"}}"); + + when(mockClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockListResponse)); + + CompletableFuture future = transport.listTools("", Collections.emptyMap()); + ExecutionException ex = assertThrows(ExecutionException.class, future::get); + Throwable cause = ex.getCause(); + if (cause instanceof java.util.concurrent.CompletionException) { + cause = cause.getCause(); + } + assertTrue( + cause instanceof com.google.cloud.mcp.exception.McpException, + "Actual cause class: " + + (cause == null ? "null" : cause.getClass().getName()) + + ", message: " + + (cause == null ? "null" : cause.getMessage())); + assertTrue(cause.getMessage().contains("MCP Error")); + } + + @Test + @SuppressWarnings("unchecked") + void testListTools_NegotiationErrorNoMutual_ThrowsException() throws Exception { + HttpResponse mockListResponse = mock(HttpResponse.class); + when(mockListResponse.statusCode()).thenReturn(200); + when(mockListResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"error\":{\"code\":-32004,\"message\":\"Unsupported" + + " protocol version\",\"data\":{\"supported\":[\"1999-01-01\"]}}}"); + + when(mockClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockListResponse)); + + CompletableFuture future = transport.listTools("", Collections.emptyMap()); + ExecutionException ex = assertThrows(ExecutionException.class, future::get); + Throwable cause = ex.getCause(); + if (cause instanceof java.util.concurrent.CompletionException) { + cause = cause.getCause(); + } + assertTrue(cause instanceof com.google.cloud.mcp.exception.McpException); + assertTrue(cause.getMessage().contains("No mutually supported protocol version")); + } + + @Test + void testClientFallback_WithMockTransport_PropagatesException() { + Transport mockTransport = mock(Transport.class); + when(mockTransport.getBaseUrl()).thenReturn("https://mock-service.com"); + + when(mockTransport.listTools(any(), any())) + .thenReturn( + CompletableFuture.failedFuture( + new McpProtocolNegotiationException("fallback requested", "2025-11-25"))); + + McpToolboxClientImpl client = new McpToolboxClientImpl(mockTransport); + + ExecutionException ex = assertThrows(ExecutionException.class, () -> client.listTools().get()); + Throwable cause = ex.getCause(); + if (cause instanceof java.util.concurrent.CompletionException) { + cause = cause.getCause(); + } + assertTrue(cause instanceof McpProtocolNegotiationException); + } + + @Test + @SuppressWarnings("unchecked") + void testClientFallback_WithInvalidVersion_PropagatesException() { + HttpClient localMockClient = mock(HttpClient.class); + HttpMcpTransport localTransport = + new HttpMcpTransport( + "https://test-mcp-service.com", + Collections.emptyMap(), + null, + ProtocolVersion.VERSION_2026_06_18, + localMockClient, + null); + + HttpResponse mockListResponse = mock(HttpResponse.class); + when(mockListResponse.statusCode()).thenReturn(200); + when(mockListResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"error\":{\"code\":-32004,\"message\":\"Unsupported\",\"data\":{\"supported\":[\"invalid-version\"]}}}"); + + when(localMockClient.sendAsync( + any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockListResponse)); + + McpToolboxClientImpl client = new McpToolboxClientImpl(localTransport); + + ExecutionException ex = assertThrows(ExecutionException.class, () -> client.listTools().get()); + Throwable cause = ex.getCause(); + if (cause instanceof java.util.concurrent.CompletionException) { + cause = cause.getCause(); + } + assertTrue(cause instanceof com.google.cloud.mcp.exception.McpException); + } +} diff --git a/src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportTest.java b/src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportTest.java index d96aa76..f87a76a 100644 --- a/src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportTest.java +++ b/src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportTest.java @@ -283,134 +283,230 @@ void testConstructor_WithCustomExecutorConfiguresHttpClient() throws Exception { @Test @SuppressWarnings("unchecked") void testInitialize_ServerReturnsErrorJsonRpcResponse() throws Exception { - HttpResponse mockInitResponse = mock(HttpResponse.class); - when(mockInitResponse.statusCode()).thenReturn(200); - when(mockInitResponse.body()) - .thenReturn( - "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"error\":{\"code\":-32603,\"message\":\"Internal" - + " error\"}}"); - - when(mockClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenReturn(CompletableFuture.completedFuture(mockInitResponse)); - - CompletableFuture future = transport.listTools("", Collections.emptyMap()); - java.util.concurrent.ExecutionException ex = - org.junit.jupiter.api.Assertions.assertThrows( - java.util.concurrent.ExecutionException.class, future::get); - assertTrue(ex.getCause() instanceof McpException); - assertTrue(ex.getCause().getMessage().contains("MCP Error")); + List versions = + List.of( + ProtocolVersion.VERSION_2025_11_25, + ProtocolVersion.VERSION_2025_06_18, + ProtocolVersion.VERSION_2025_03_26, + ProtocolVersion.VERSION_2024_11_05); + for (ProtocolVersion version : versions) { + HttpClient localMockClient = mock(HttpClient.class); + HttpMcpTransport versionedTransport = + new HttpMcpTransport( + "https://test-mcp-service.com", + Collections.emptyMap(), + null, + version, + localMockClient, + null); + + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(200); + when(mockInitResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"error\":{\"code\":-32603,\"message\":\"Internal" + + " error\"}}"); + + when(localMockClient.sendAsync( + any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockInitResponse)); + + CompletableFuture future = + versionedTransport.listTools("", Collections.emptyMap()); + java.util.concurrent.ExecutionException ex = + org.junit.jupiter.api.Assertions.assertThrows( + java.util.concurrent.ExecutionException.class, future::get); + assertTrue(ex.getCause() instanceof McpException); + assertTrue(ex.getCause().getMessage().contains("MCP Error")); + } } @Test @SuppressWarnings("unchecked") - void testListTools_WithHttpUrlAndMetadata_LogsWarning() throws Exception { - HttpMcpTransport httpTransport = - new HttpMcpTransport("http://test-mcp-service.com", mockClient); - HttpResponse mockInitResponse = mock(HttpResponse.class); - when(mockInitResponse.statusCode()).thenReturn(200); - when(mockInitResponse.body()) - .thenReturn( - "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"2025-11-25\"}}"); - - HttpResponse mockInitializedResponse = mock(HttpResponse.class); - when(mockInitializedResponse.statusCode()).thenReturn(200); - when(mockInitializedResponse.body()).thenReturn(""); - - HttpResponse mockListResponse = mock(HttpResponse.class); - when(mockListResponse.statusCode()).thenReturn(200); - when(mockListResponse.body()) - .thenReturn("{\"jsonrpc\":\"2.0\",\"id\":\"2\",\"result\":{\"tools\":[]}}"); - - when(mockClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenReturn(CompletableFuture.completedFuture(mockInitResponse)) - .thenReturn(CompletableFuture.completedFuture(mockInitializedResponse)) - .thenReturn(CompletableFuture.completedFuture(mockListResponse)); - - java.util.logging.Logger transportLogger = - java.util.logging.Logger.getLogger(BaseMcpTransport.class.getName()); - java.util.List logRecords = new java.util.ArrayList<>(); - java.util.logging.Handler logHandler = - new java.util.logging.Handler() { - @Override - public void publish(java.util.logging.LogRecord record) { - logRecords.add(record); - } - - @Override - public void flush() {} - - @Override - public void close() throws SecurityException {} - }; - transportLogger.addHandler(logHandler); - - try { - httpTransport.listTools("", Map.of("key", "val")).get(); - } finally { - transportLogger.removeHandler(logHandler); + void testListTools_Non200Response_ThrowsException() { + List versions = + List.of( + ProtocolVersion.VERSION_2025_11_25, + ProtocolVersion.VERSION_2025_06_18, + ProtocolVersion.VERSION_2025_03_26, + ProtocolVersion.VERSION_2024_11_05); + for (ProtocolVersion version : versions) { + HttpClient localMockClient = mock(HttpClient.class); + HttpMcpTransport versionedTransport = + new HttpMcpTransport( + "https://test-mcp-service.com", + Collections.emptyMap(), + null, + version, + localMockClient, + null); + + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(200); + when(mockInitResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"" + + version.getValue() + + "\"}}"); + java.net.http.HttpHeaders mockHeaders = + java.net.http.HttpHeaders.of( + Map.of("Mcp-Session-Id", List.of("test-session-123")), (k, v) -> true); + when(mockInitResponse.headers()).thenReturn(mockHeaders); + + HttpResponse mockInitializedResponse = mock(HttpResponse.class); + when(mockInitializedResponse.statusCode()).thenReturn(200); + when(mockInitializedResponse.body()).thenReturn(""); + + HttpResponse mockErrorResponse = mock(HttpResponse.class); + when(mockErrorResponse.statusCode()).thenReturn(500); + when(mockErrorResponse.body()).thenReturn("Internal Server Error"); + + when(localMockClient.sendAsync( + any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockInitResponse)) + .thenReturn(CompletableFuture.completedFuture(mockInitializedResponse)) + .thenReturn(CompletableFuture.completedFuture(mockErrorResponse)); + + Exception ex = + org.junit.jupiter.api.Assertions.assertThrows( + Exception.class, + () -> versionedTransport.listTools("", Collections.emptyMap()).get()); + assertTrue(ex.getCause().getMessage().contains("Status: 500")); } - - assertFalse(logRecords.isEmpty()); - assertTrue(logRecords.get(0).getMessage().contains("This connection is using HTTP")); } @Test @SuppressWarnings("unchecked") - void testListTools_Non200Response_ThrowsException() { - HttpResponse mockInitResponse = mock(HttpResponse.class); - when(mockInitResponse.statusCode()).thenReturn(200); - when(mockInitResponse.body()) - .thenReturn( - "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"2025-11-25\"}}"); - - HttpResponse mockInitializedResponse = mock(HttpResponse.class); - when(mockInitializedResponse.statusCode()).thenReturn(200); - when(mockInitializedResponse.body()).thenReturn(""); - - HttpResponse mockErrorResponse = mock(HttpResponse.class); - when(mockErrorResponse.statusCode()).thenReturn(500); - when(mockErrorResponse.body()).thenReturn("Internal Server Error"); - - when(mockClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenReturn(CompletableFuture.completedFuture(mockInitResponse)) - .thenReturn(CompletableFuture.completedFuture(mockInitializedResponse)) - .thenReturn(CompletableFuture.completedFuture(mockErrorResponse)); - - Exception ex = - org.junit.jupiter.api.Assertions.assertThrows( - Exception.class, () -> transport.listTools("", Collections.emptyMap()).get()); - assertTrue(ex.getCause().getMessage().contains("Status: 500")); + void testListTools_JsonRpcError_ThrowsException() { + List versions = + List.of( + ProtocolVersion.VERSION_2025_11_25, + ProtocolVersion.VERSION_2025_06_18, + ProtocolVersion.VERSION_2025_03_26, + ProtocolVersion.VERSION_2024_11_05); + for (ProtocolVersion version : versions) { + HttpClient localMockClient = mock(HttpClient.class); + HttpMcpTransport versionedTransport = + new HttpMcpTransport( + "https://test-mcp-service.com", + Collections.emptyMap(), + null, + version, + localMockClient, + null); + + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(200); + when(mockInitResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"" + + version.getValue() + + "\"}}"); + java.net.http.HttpHeaders mockHeaders = + java.net.http.HttpHeaders.of( + Map.of("Mcp-Session-Id", List.of("test-session-123")), (k, v) -> true); + when(mockInitResponse.headers()).thenReturn(mockHeaders); + + HttpResponse mockInitializedResponse = mock(HttpResponse.class); + when(mockInitializedResponse.statusCode()).thenReturn(200); + when(mockInitializedResponse.body()).thenReturn(""); + + HttpResponse mockErrorResponse = mock(HttpResponse.class); + when(mockErrorResponse.statusCode()).thenReturn(200); + when(mockErrorResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"2\",\"error\":{\"code\":-1,\"message\":\"Custom" + + " error\"}}"); + + when(localMockClient.sendAsync( + any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockInitResponse)) + .thenReturn(CompletableFuture.completedFuture(mockInitializedResponse)) + .thenReturn(CompletableFuture.completedFuture(mockErrorResponse)); + + Exception ex = + org.junit.jupiter.api.Assertions.assertThrows( + Exception.class, + () -> versionedTransport.listTools("", Collections.emptyMap()).get()); + assertTrue(ex.getCause().getMessage().contains("Custom error")); + } } @Test @SuppressWarnings("unchecked") - void testListTools_JsonRpcError_ThrowsException() { - HttpResponse mockInitResponse = mock(HttpResponse.class); - when(mockInitResponse.statusCode()).thenReturn(200); - when(mockInitResponse.body()) - .thenReturn( - "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"2025-11-25\"}}"); - - HttpResponse mockInitializedResponse = mock(HttpResponse.class); - when(mockInitializedResponse.statusCode()).thenReturn(200); - when(mockInitializedResponse.body()).thenReturn(""); - - HttpResponse mockErrorResponse = mock(HttpResponse.class); - when(mockErrorResponse.statusCode()).thenReturn(200); - when(mockErrorResponse.body()) - .thenReturn( - "{\"jsonrpc\":\"2.0\",\"id\":\"2\",\"error\":{\"code\":-1,\"message\":\"Custom" - + " error\"}}"); - - when(mockClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenReturn(CompletableFuture.completedFuture(mockInitResponse)) - .thenReturn(CompletableFuture.completedFuture(mockInitializedResponse)) - .thenReturn(CompletableFuture.completedFuture(mockErrorResponse)); + void testInitialize_VersionMismatch_ThrowsException() { + List versions = + List.of( + ProtocolVersion.VERSION_2025_11_25, + ProtocolVersion.VERSION_2025_06_18, + ProtocolVersion.VERSION_2025_03_26, + ProtocolVersion.VERSION_2024_11_05); + for (ProtocolVersion version : versions) { + HttpClient localMockClient = mock(HttpClient.class); + HttpMcpTransport versionedTransport = + new HttpMcpTransport( + "https://test-mcp-service.com", + Collections.emptyMap(), + null, + version, + localMockClient, + null); + + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(200); + when(mockInitResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"2000-01-01\"}}"); + + when(localMockClient.sendAsync( + any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockInitResponse)); + + Exception ex = + org.junit.jupiter.api.Assertions.assertThrows( + Exception.class, + () -> versionedTransport.listTools("", Collections.emptyMap()).get()); + assertTrue(ex.getCause().getMessage().contains("version mismatch")); + } + } - Exception ex = - org.junit.jupiter.api.Assertions.assertThrows( - Exception.class, () -> transport.listTools("", Collections.emptyMap()).get()); - assertTrue(ex.getCause().getMessage().contains("Custom error")); + @Test + @SuppressWarnings("unchecked") + void testInitialize_MissingSessionIdHeader_ThrowsException() { + List sessionVer = List.of(ProtocolVersion.VERSION_2025_03_26); + for (ProtocolVersion version : sessionVer) { + HttpClient localMockClient = mock(HttpClient.class); + HttpMcpTransport versionedTransport = + new HttpMcpTransport( + "https://test-mcp-service.com", + Collections.emptyMap(), + null, + version, + localMockClient, + null); + + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(200); + when(mockInitResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"" + + version.getValue() + + "\"}}"); + java.net.http.HttpHeaders mockHeaders = + java.net.http.HttpHeaders.of(Map.of(), (k, v) -> true); + when(mockInitResponse.headers()).thenReturn(mockHeaders); + + when(localMockClient.sendAsync( + any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockInitResponse)); + + Exception ex = + org.junit.jupiter.api.Assertions.assertThrows( + Exception.class, + () -> versionedTransport.listTools("", Collections.emptyMap()).get()); + assertTrue( + ex.getCause().getMessage().contains("Server did not return a Mcp-Session-Id header")); + } } @Test @@ -474,4 +570,161 @@ void testListTools_ParsesComplexToolsCorrectly() throws Exception { assertFalse(p2.required()); assertEquals("string", p2.type()); } + + @Test + @SuppressWarnings("unchecked") + void testListTools_AllStatefulVersions() throws Exception { + List statefulVersions = + List.of( + ProtocolVersion.VERSION_2025_11_25, + ProtocolVersion.VERSION_2025_06_18, + ProtocolVersion.VERSION_2025_03_26, + ProtocolVersion.VERSION_2024_11_05); + + for (ProtocolVersion version : statefulVersions) { + HttpClient localMockClient = mock(HttpClient.class); + HttpMcpTransport versionedTransport = + new HttpMcpTransport( + "https://test-mcp-service.com", + Collections.emptyMap(), + null, + version, + localMockClient, + null); + + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(200); + when(mockInitResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"" + + version.getValue() + + "\"}}"); + java.net.http.HttpHeaders mockHeaders = + java.net.http.HttpHeaders.of( + Map.of("Mcp-Session-Id", List.of("test-session-123")), (k, v) -> true); + when(mockInitResponse.headers()).thenReturn(mockHeaders); + + HttpResponse mockInitializedResponse = mock(HttpResponse.class); + when(mockInitializedResponse.statusCode()).thenReturn(200); + when(mockInitializedResponse.body()).thenReturn(""); + + HttpResponse mockListResponse = mock(HttpResponse.class); + when(mockListResponse.statusCode()).thenReturn(200); + when(mockListResponse.body()) + .thenReturn("{\"jsonrpc\":\"2.0\",\"id\":\"2\",\"result\":{\"tools\":[]}}"); + + when(localMockClient.sendAsync( + any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockInitResponse)) + .thenReturn(CompletableFuture.completedFuture(mockInitializedResponse)) + .thenReturn(CompletableFuture.completedFuture(mockListResponse)); + + TransportManifest manifest = versionedTransport.listTools("", Collections.emptyMap()).get(); + assertNotNull(manifest); + } + } + + @Test + @SuppressWarnings("unchecked") + void testInvokeTool_AllStatefulVersions() throws Exception { + List statefulVersions = + List.of( + ProtocolVersion.VERSION_2025_11_25, + ProtocolVersion.VERSION_2025_06_18, + ProtocolVersion.VERSION_2025_03_26, + ProtocolVersion.VERSION_2024_11_05); + + for (ProtocolVersion version : statefulVersions) { + HttpClient localMockClient = mock(HttpClient.class); + HttpMcpTransport versionedTransport = + new HttpMcpTransport( + "https://test-mcp-service.com", + Collections.emptyMap(), + null, + version, + localMockClient, + null); + + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(200); + when(mockInitResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"" + + version.getValue() + + "\"}}"); + java.net.http.HttpHeaders mockHeaders = + java.net.http.HttpHeaders.of( + Map.of("Mcp-Session-Id", List.of("test-session-123")), (k, v) -> true); + when(mockInitResponse.headers()).thenReturn(mockHeaders); + + HttpResponse mockInitializedResponse = mock(HttpResponse.class); + when(mockInitializedResponse.statusCode()).thenReturn(200); + when(mockInitializedResponse.body()).thenReturn(""); + + HttpResponse mockInvokeResponse = mock(HttpResponse.class); + when(mockInvokeResponse.statusCode()).thenReturn(200); + when(mockInvokeResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"3\",\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"success\"}]}}"); + + when(localMockClient.sendAsync( + any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockInitResponse)) + .thenReturn(CompletableFuture.completedFuture(mockInitializedResponse)) + .thenReturn(CompletableFuture.completedFuture(mockInvokeResponse)); + + TransportResponse response = + versionedTransport.invokeTool("test-tool", Map.of(), Collections.emptyMap()).get(); + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + } + } + + @Test + void testProtocolVersionEnumMethods() { + assertNotNull(ProtocolVersion.values()); + assertEquals(ProtocolVersion.VERSION_2026_06_18, ProtocolVersion.valueOf("VERSION_2026_06_18")); + org.junit.jupiter.api.Assertions.assertNull(ProtocolVersion.fromString("invalid-version")); + } + + @Test + @SuppressWarnings("unchecked") + void testInitialize_Non200Response_ThrowsException() { + List versions = + List.of( + ProtocolVersion.VERSION_2025_11_25, + ProtocolVersion.VERSION_2025_06_18, + ProtocolVersion.VERSION_2025_03_26, + ProtocolVersion.VERSION_2024_11_05); + for (ProtocolVersion version : versions) { + HttpClient localMockClient = mock(HttpClient.class); + HttpMcpTransport versionedTransport = + new HttpMcpTransport( + "https://test-mcp-service.com", + Collections.emptyMap(), + null, + version, + localMockClient, + null); + + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(500); + when(mockInitResponse.body()).thenReturn("Init Server Error"); + + when(localMockClient.sendAsync( + any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockInitResponse)); + + Exception ex = + org.junit.jupiter.api.Assertions.assertThrows( + Exception.class, + () -> versionedTransport.listTools("", Collections.emptyMap()).get()); + assertTrue(ex.getCause().getMessage().contains("Init failed: 500")); + } + } + + @Test + void testClose() throws Exception { + transport.close(); + } } From 7fc1e3968e5278bb9a5b09480471d2970424a421 Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Fri, 26 Jun 2026 23:41:41 +0530 Subject: [PATCH 3/3] Add advanced initialization error and reflection test coverage, split tests to obey 700 lines limit --- ...pMcpTransportInitializationErrorsTest.java | 665 ++++++++++++++++++ .../HttpMcpTransportStatelessTest.java | 18 + .../mcp/transport/HttpMcpTransportTest.java | 297 ++------ 3 files changed, 725 insertions(+), 255 deletions(-) create mode 100644 src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportInitializationErrorsTest.java diff --git a/src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportInitializationErrorsTest.java b/src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportInitializationErrorsTest.java new file mode 100644 index 0000000..26abd5b --- /dev/null +++ b/src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportInitializationErrorsTest.java @@ -0,0 +1,665 @@ +/* + * 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.transport; + +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 static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.cloud.mcp.ProtocolVersion; +import com.google.cloud.mcp.exception.McpException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +@Timeout(value = 5, unit = java.util.concurrent.TimeUnit.SECONDS) +class HttpMcpTransportInitializationErrorsTest { + + private Object getDelegate(HttpMcpTransport transport) throws Exception { + java.lang.reflect.Field field = HttpMcpTransport.class.getDeclaredField("delegate"); + field.setAccessible(true); + return field.get(transport); + } + + private void setMockObjectMapper(BaseMcpTransport transport, ObjectMapper mockMapper) + throws Exception { + java.lang.reflect.Field field = BaseMcpTransport.class.getDeclaredField("objectMapper"); + field.setAccessible(true); + field.set(transport, mockMapper); + } + + @Test + @SuppressWarnings("unchecked") + void testInitialize_ServerReturnsErrorJsonRpcResponse() throws Exception { + List versions = + List.of( + ProtocolVersion.VERSION_2025_11_25, + ProtocolVersion.VERSION_2025_06_18, + ProtocolVersion.VERSION_2025_03_26, + ProtocolVersion.VERSION_2024_11_05); + for (ProtocolVersion version : versions) { + HttpClient localMockClient = mock(HttpClient.class); + HttpMcpTransport versionedTransport = + new HttpMcpTransport( + "https://test-mcp-service.com", + Collections.emptyMap(), + null, + version, + localMockClient, + null); + + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(200); + when(mockInitResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"error\":{\"code\":-32603,\"message\":\"Internal" + + " error\"}}"); + + when(localMockClient.sendAsync( + any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockInitResponse)); + + CompletableFuture future = + versionedTransport.listTools("", Collections.emptyMap()); + java.util.concurrent.ExecutionException ex = + org.junit.jupiter.api.Assertions.assertThrows( + java.util.concurrent.ExecutionException.class, future::get); + assertTrue(ex.getCause() instanceof McpException); + assertTrue(ex.getCause().getMessage().contains("MCP Error")); + } + } + + @Test + @SuppressWarnings("unchecked") + void testListTools_Non200Response_ThrowsException() { + List versions = + List.of( + ProtocolVersion.VERSION_2025_11_25, + ProtocolVersion.VERSION_2025_06_18, + ProtocolVersion.VERSION_2025_03_26, + ProtocolVersion.VERSION_2024_11_05); + for (ProtocolVersion version : versions) { + HttpClient localMockClient = mock(HttpClient.class); + HttpMcpTransport versionedTransport = + new HttpMcpTransport( + "https://test-mcp-service.com", + Collections.emptyMap(), + null, + version, + localMockClient, + null); + + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(200); + when(mockInitResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"" + + version.getValue() + + "\"}}"); + java.net.http.HttpHeaders mockHeaders = + java.net.http.HttpHeaders.of( + Map.of("Mcp-Session-Id", List.of("test-session-123")), (k, v) -> true); + when(mockInitResponse.headers()).thenReturn(mockHeaders); + + HttpResponse mockInitializedResponse = mock(HttpResponse.class); + when(mockInitializedResponse.statusCode()).thenReturn(200); + when(mockInitializedResponse.body()).thenReturn(""); + + HttpResponse mockErrorResponse = mock(HttpResponse.class); + when(mockErrorResponse.statusCode()).thenReturn(500); + when(mockErrorResponse.body()).thenReturn("Internal Server Error"); + + when(localMockClient.sendAsync( + any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockInitResponse)) + .thenReturn(CompletableFuture.completedFuture(mockInitializedResponse)) + .thenReturn(CompletableFuture.completedFuture(mockErrorResponse)); + + Exception ex = + org.junit.jupiter.api.Assertions.assertThrows( + Exception.class, + () -> versionedTransport.listTools("", Collections.emptyMap()).get()); + assertTrue(ex.getCause().getMessage().contains("Status: 500")); + } + } + + @Test + @SuppressWarnings("unchecked") + void testListTools_JsonRpcError_ThrowsException() { + List versions = + List.of( + ProtocolVersion.VERSION_2025_11_25, + ProtocolVersion.VERSION_2025_06_18, + ProtocolVersion.VERSION_2025_03_26, + ProtocolVersion.VERSION_2024_11_05); + for (ProtocolVersion version : versions) { + HttpClient localMockClient = mock(HttpClient.class); + HttpMcpTransport versionedTransport = + new HttpMcpTransport( + "https://test-mcp-service.com", + Collections.emptyMap(), + null, + version, + localMockClient, + null); + + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(200); + when(mockInitResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"" + + version.getValue() + + "\"}}"); + java.net.http.HttpHeaders mockHeaders = + java.net.http.HttpHeaders.of( + Map.of("Mcp-Session-Id", List.of("test-session-123")), (k, v) -> true); + when(mockInitResponse.headers()).thenReturn(mockHeaders); + + HttpResponse mockInitializedResponse = mock(HttpResponse.class); + when(mockInitializedResponse.statusCode()).thenReturn(200); + when(mockInitializedResponse.body()).thenReturn(""); + + HttpResponse mockErrorResponse = mock(HttpResponse.class); + when(mockErrorResponse.statusCode()).thenReturn(200); + when(mockErrorResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"2\",\"error\":{\"code\":-1,\"message\":\"Custom" + + " error\"}}"); + + when(localMockClient.sendAsync( + any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockInitResponse)) + .thenReturn(CompletableFuture.completedFuture(mockInitializedResponse)) + .thenReturn(CompletableFuture.completedFuture(mockErrorResponse)); + + Exception ex = + org.junit.jupiter.api.Assertions.assertThrows( + Exception.class, + () -> versionedTransport.listTools("", Collections.emptyMap()).get()); + assertTrue(ex.getCause().getMessage().contains("Custom error")); + } + } + + @Test + @SuppressWarnings("unchecked") + void testInitialize_VersionMismatch_ThrowsException() { + List versions = + List.of( + ProtocolVersion.VERSION_2025_11_25, + ProtocolVersion.VERSION_2025_06_18, + ProtocolVersion.VERSION_2025_03_26, + ProtocolVersion.VERSION_2024_11_05); + for (ProtocolVersion version : versions) { + HttpClient localMockClient = mock(HttpClient.class); + HttpMcpTransport versionedTransport = + new HttpMcpTransport( + "https://test-mcp-service.com", + Collections.emptyMap(), + null, + version, + localMockClient, + null); + + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(200); + when(mockInitResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"2000-01-01\"}}"); + + when(localMockClient.sendAsync( + any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockInitResponse)); + + Exception ex = + org.junit.jupiter.api.Assertions.assertThrows( + Exception.class, + () -> versionedTransport.listTools("", Collections.emptyMap()).get()); + assertTrue(ex.getCause().getMessage().contains("version mismatch")); + } + } + + @Test + @SuppressWarnings("unchecked") + void testInitialize_MissingSessionIdHeader_ThrowsException() { + List sessionVer = List.of(ProtocolVersion.VERSION_2025_03_26); + for (ProtocolVersion version : sessionVer) { + HttpClient localMockClient = mock(HttpClient.class); + HttpMcpTransport versionedTransport = + new HttpMcpTransport( + "https://test-mcp-service.com", + Collections.emptyMap(), + null, + version, + localMockClient, + null); + + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(200); + when(mockInitResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"" + + version.getValue() + + "\"}}"); + java.net.http.HttpHeaders mockHeaders = + java.net.http.HttpHeaders.of(Map.of(), (k, v) -> true); + when(mockInitResponse.headers()).thenReturn(mockHeaders); + + when(localMockClient.sendAsync( + any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockInitResponse)); + + Exception ex = + org.junit.jupiter.api.Assertions.assertThrows( + Exception.class, + () -> versionedTransport.listTools("", Collections.emptyMap()).get()); + assertTrue( + ex.getCause().getMessage().contains("Server did not return a Mcp-Session-Id header")); + } + } + + @Test + @SuppressWarnings("unchecked") + void testInitialize_Non200Response_ThrowsException() { + List versions = + List.of( + ProtocolVersion.VERSION_2025_11_25, + ProtocolVersion.VERSION_2025_06_18, + ProtocolVersion.VERSION_2025_03_26, + ProtocolVersion.VERSION_2024_11_05); + for (ProtocolVersion version : versions) { + HttpClient localMockClient = mock(HttpClient.class); + HttpMcpTransport versionedTransport = + new HttpMcpTransport( + "https://test-mcp-service.com", + Collections.emptyMap(), + null, + version, + localMockClient, + null); + + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(500); + when(mockInitResponse.body()).thenReturn("Init Server Error"); + + when(localMockClient.sendAsync( + any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockInitResponse)); + + Exception ex = + org.junit.jupiter.api.Assertions.assertThrows( + Exception.class, + () -> versionedTransport.listTools("", Collections.emptyMap()).get()); + assertTrue(ex.getCause().getMessage().contains("Init failed: 500")); + } + } + + @Test + @SuppressWarnings("unchecked") + void testInitialize_MissingProtocolVersionInResult_FallbackToDefault() throws Exception { + List versions = + List.of( + ProtocolVersion.VERSION_2025_11_25, + ProtocolVersion.VERSION_2025_06_18, + ProtocolVersion.VERSION_2025_03_26, + ProtocolVersion.VERSION_2024_11_05); + for (ProtocolVersion version : versions) { + HttpClient localMockClient = mock(HttpClient.class); + HttpMcpTransport versionedTransport = + new HttpMcpTransport( + "https://test-mcp-service.com", + Collections.emptyMap(), + null, + version, + localMockClient, + null); + + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(200); + when(mockInitResponse.body()).thenReturn("{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{}}"); + java.net.http.HttpHeaders mockHeaders = + java.net.http.HttpHeaders.of( + Map.of("Mcp-Session-Id", List.of("test-session-123")), (k, v) -> true); + when(mockInitResponse.headers()).thenReturn(mockHeaders); + + HttpResponse mockInitializedResponse = mock(HttpResponse.class); + when(mockInitializedResponse.statusCode()).thenReturn(200); + when(mockInitializedResponse.body()).thenReturn(""); + + HttpResponse mockListResponse = mock(HttpResponse.class); + when(mockListResponse.statusCode()).thenReturn(200); + when(mockListResponse.body()) + .thenReturn("{\"jsonrpc\":\"2.0\",\"id\":\"2\",\"result\":{\"tools\":[]}}"); + + when(localMockClient.sendAsync( + any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockInitResponse)) + .thenReturn(CompletableFuture.completedFuture(mockInitializedResponse)) + .thenReturn(CompletableFuture.completedFuture(mockListResponse)); + + TransportManifest manifest = versionedTransport.listTools("", Collections.emptyMap()).get(); + assertNotNull(manifest); + } + } + + @Test + @SuppressWarnings("unchecked") + void testInitialize_NullResult_FallbackToDefault() throws Exception { + List versions = + List.of( + ProtocolVersion.VERSION_2025_11_25, + ProtocolVersion.VERSION_2025_06_18, + ProtocolVersion.VERSION_2025_03_26, + ProtocolVersion.VERSION_2024_11_05); + for (ProtocolVersion version : versions) { + HttpClient localMockClient = mock(HttpClient.class); + HttpMcpTransport versionedTransport = + new HttpMcpTransport( + "https://test-mcp-service.com", + Collections.emptyMap(), + null, + version, + localMockClient, + null); + + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(200); + when(mockInitResponse.body()) + .thenReturn("{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":null}"); + java.net.http.HttpHeaders mockHeaders = + java.net.http.HttpHeaders.of( + Map.of("Mcp-Session-Id", List.of("test-session-123")), (k, v) -> true); + when(mockInitResponse.headers()).thenReturn(mockHeaders); + + HttpResponse mockInitializedResponse = mock(HttpResponse.class); + when(mockInitializedResponse.statusCode()).thenReturn(200); + when(mockInitializedResponse.body()).thenReturn(""); + + HttpResponse mockListResponse = mock(HttpResponse.class); + when(mockListResponse.statusCode()).thenReturn(200); + when(mockListResponse.body()) + .thenReturn("{\"jsonrpc\":\"2.0\",\"id\":\"2\",\"result\":{\"tools\":[]}}"); + + when(localMockClient.sendAsync( + any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockInitResponse)) + .thenReturn(CompletableFuture.completedFuture(mockInitializedResponse)) + .thenReturn(CompletableFuture.completedFuture(mockListResponse)); + + TransportManifest manifest = versionedTransport.listTools("", Collections.emptyMap()).get(); + assertNotNull(manifest); + } + } + + @Test + @SuppressWarnings("unchecked") + void testInitialize_InitializedNotificationThrowsException() { + List versions = + List.of( + ProtocolVersion.VERSION_2025_11_25, + ProtocolVersion.VERSION_2025_06_18, + ProtocolVersion.VERSION_2025_03_26, + ProtocolVersion.VERSION_2024_11_05); + for (ProtocolVersion version : versions) { + HttpClient localMockClient = mock(HttpClient.class); + HttpMcpTransport versionedTransport = + new HttpMcpTransport( + "https://test-mcp-service.com", + Collections.emptyMap(), + null, + version, + localMockClient, + null); + + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(200); + when(mockInitResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"" + + version.getValue() + + "\"}}"); + java.net.http.HttpHeaders mockHeaders = + java.net.http.HttpHeaders.of( + Map.of("Mcp-Session-Id", List.of("test-session-123")), (k, v) -> true); + when(mockInitResponse.headers()).thenReturn(mockHeaders); + + when(localMockClient.sendAsync( + any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockInitResponse)) + .thenReturn(CompletableFuture.failedFuture(new java.io.IOException("Connection reset"))); + + Exception ex = + org.junit.jupiter.api.Assertions.assertThrows( + Exception.class, + () -> versionedTransport.listTools("", Collections.emptyMap()).get()); + assertTrue(ex.getCause().getMessage().contains("Connection reset")); + } + } + + @Test + @SuppressWarnings("unchecked") + void testInitialize_JsonProcessingExceptionDuringRequestSerialization_ThrowsException() + throws Exception { + List versions = + List.of( + ProtocolVersion.VERSION_2025_11_25, + ProtocolVersion.VERSION_2025_06_18, + ProtocolVersion.VERSION_2025_03_26, + ProtocolVersion.VERSION_2024_11_05); + for (ProtocolVersion version : versions) { + HttpClient localMockClient = mock(HttpClient.class); + HttpMcpTransport versionedTransport = + new HttpMcpTransport( + "https://test-mcp-service.com", + Collections.emptyMap(), + null, + version, + localMockClient, + null); + + BaseMcpTransport delegate = (BaseMcpTransport) getDelegate(versionedTransport); + + ObjectMapper mockMapper = mock(ObjectMapper.class); + when(mockMapper.writeValueAsString(any())) + .thenThrow(new com.fasterxml.jackson.core.JsonParseException(null, "Mock JSON Error")); + + setMockObjectMapper(delegate, mockMapper); + + Exception ex = + org.junit.jupiter.api.Assertions.assertThrows( + Exception.class, + () -> versionedTransport.listTools("", Collections.emptyMap()).get()); + assertTrue(ex.getCause().getMessage().contains("Mock JSON Error")); + } + } + + @Test + @SuppressWarnings("unchecked") + void testInitialize_JsonProcessingExceptionDuringNotificationSerialization_ThrowsException() + throws Exception { + List versions = + List.of( + ProtocolVersion.VERSION_2025_11_25, + ProtocolVersion.VERSION_2025_06_18, + ProtocolVersion.VERSION_2025_03_26, + ProtocolVersion.VERSION_2024_11_05); + for (ProtocolVersion version : versions) { + HttpClient localMockClient = mock(HttpClient.class); + HttpMcpTransport versionedTransport = + new HttpMcpTransport( + "https://test-mcp-service.com", + Collections.emptyMap(), + null, + version, + localMockClient, + null); + + BaseMcpTransport delegate = (BaseMcpTransport) getDelegate(versionedTransport); + + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(200); + when(mockInitResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"" + + version.getValue() + + "\"}}"); + java.net.http.HttpHeaders mockHeaders = + java.net.http.HttpHeaders.of( + Map.of("Mcp-Session-Id", List.of("test-session-123")), (k, v) -> true); + when(mockInitResponse.headers()).thenReturn(mockHeaders); + + ObjectMapper mockMapper = mock(ObjectMapper.class); + when(mockMapper.writeValueAsString(any())) + .thenReturn("{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"method\":\"initialize\"}") + .thenThrow(new com.fasterxml.jackson.core.JsonParseException(null, "Mock JSON Error 2")); + + com.fasterxml.jackson.databind.JsonNode mockNode = + new ObjectMapper() + .readTree( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"" + + version.getValue() + + "\"}}"); + when(mockMapper.readTree(anyString())).thenReturn(mockNode); + + setMockObjectMapper(delegate, mockMapper); + + when(localMockClient.sendAsync( + any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockInitResponse)); + + Exception ex = + org.junit.jupiter.api.Assertions.assertThrows( + Exception.class, + () -> versionedTransport.listTools("", Collections.emptyMap()).get()); + assertTrue(ex.getCause().getMessage().contains("Mock JSON Error 2")); + } + } + + @Test + void testHttpMcpTransportV20250326_SessionIdNull_HeaderNotAdded() throws Exception { + com.google.cloud.mcp.transport.v20250326.HttpMcpTransportV20250326 transportV20250326 = + new com.google.cloud.mcp.transport.v20250326.HttpMcpTransportV20250326( + "https://test.com", Map.of(), null, mock(HttpClient.class), null); + + java.lang.reflect.Method method = + com.google.cloud.mcp.transport.v20250326.HttpMcpTransportV20250326.class.getDeclaredMethod( + "applyProtocolHeaders", HttpRequest.Builder.class); + method.setAccessible(true); + + HttpRequest.Builder builder = HttpRequest.newBuilder().uri(URI.create("https://test.com")); + method.invoke(transportV20250326, builder); + + HttpRequest req = builder.build(); + assertFalse(req.headers().firstValue("Mcp-Session-Id").isPresent()); + } + + @Test + @SuppressWarnings("unchecked") + void testInitialize_HttpUrlWithCredentialsProvider_LogsWarning() throws Exception { + List versions = + List.of( + ProtocolVersion.VERSION_2025_11_25, + ProtocolVersion.VERSION_2025_06_18, + ProtocolVersion.VERSION_2025_03_26, + ProtocolVersion.VERSION_2024_11_05); + for (ProtocolVersion version : versions) { + HttpClient localMockClient = mock(HttpClient.class); + com.google.cloud.mcp.auth.CredentialsProvider mockProvider = + () -> CompletableFuture.completedFuture("Bearer test-token"); + + HttpMcpTransport versionedTransport = + new HttpMcpTransport( + "http://test-mcp-service.com", + Collections.emptyMap(), + mockProvider, + version, + localMockClient, + null); + + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(200); + when(mockInitResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"" + + version.getValue() + + "\"}}"); + java.net.http.HttpHeaders mockHeaders = + java.net.http.HttpHeaders.of( + Map.of("Mcp-Session-Id", List.of("test-session-123")), (k, v) -> true); + when(mockInitResponse.headers()).thenReturn(mockHeaders); + + HttpResponse mockInitializedResponse = mock(HttpResponse.class); + when(mockInitializedResponse.statusCode()).thenReturn(200); + when(mockInitializedResponse.body()).thenReturn(""); + + HttpResponse mockListResponse = mock(HttpResponse.class); + when(mockListResponse.statusCode()).thenReturn(200); + when(mockListResponse.body()) + .thenReturn("{\"jsonrpc\":\"2.0\",\"id\":\"2\",\"result\":{\"tools\":[]}}"); + + when(localMockClient.sendAsync( + any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockInitResponse)) + .thenReturn(CompletableFuture.completedFuture(mockInitializedResponse)) + .thenReturn(CompletableFuture.completedFuture(mockListResponse)); + + java.util.logging.Logger transportLogger = + java.util.logging.Logger.getLogger(BaseMcpTransport.class.getName()); + java.util.List logRecords = new java.util.ArrayList<>(); + java.util.logging.Handler logHandler = + new java.util.logging.Handler() { + @Override + public void publish(java.util.logging.LogRecord record) { + logRecords.add(record); + } + + @Override + public void flush() {} + + @Override + public void close() throws SecurityException {} + }; + transportLogger.addHandler(logHandler); + + try { + versionedTransport.listTools("", Collections.emptyMap()).get(); + } finally { + transportLogger.removeHandler(logHandler); + } + + assertFalse(logRecords.isEmpty()); + boolean hasWarning = + logRecords.stream() + .anyMatch(r -> r.getMessage().contains("This connection is using HTTP")); + assertTrue(hasWarning); + } + } + + private static String anyString() { + return org.mockito.ArgumentMatchers.anyString(); + } +} diff --git a/src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportStatelessTest.java b/src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportStatelessTest.java index a7dd764..75eabe4 100644 --- a/src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportStatelessTest.java +++ b/src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportStatelessTest.java @@ -18,6 +18,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; @@ -309,5 +310,22 @@ void testClientFallback_WithInvalidVersion_PropagatesException() { cause = cause.getCause(); } assertTrue(cause instanceof com.google.cloud.mcp.exception.McpException); + assertTrue(cause.getMessage().contains("No mutually supported protocol version")); + } + + @Test + void testHttpMcpTransportV20260618_ModifyRequestParams_Fallback() throws Exception { + com.google.cloud.mcp.transport.v20260618.HttpMcpTransportV20260618 transportV20260618 = + new com.google.cloud.mcp.transport.v20260618.HttpMcpTransportV20260618( + "https://test.com", Map.of(), null, mock(HttpClient.class), null); + + java.lang.reflect.Method method = + com.google.cloud.mcp.transport.v20260618.HttpMcpTransportV20260618.class.getDeclaredMethod( + "modifyRequestParams", String.class, Object.class); + method.setAccessible(true); + + Object inputParams = new Object(); + Object result = method.invoke(transportV20260618, "other/method", inputParams); + assertSame(inputParams, result); } } diff --git a/src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportTest.java b/src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportTest.java index f87a76a..09a6f83 100644 --- a/src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportTest.java +++ b/src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportTest.java @@ -28,7 +28,6 @@ import com.google.cloud.mcp.ProtocolVersion; import com.google.cloud.mcp.auth.CredentialsProvider; -import com.google.cloud.mcp.exception.McpException; import com.google.cloud.mcp.tool.ToolDefinition; import java.net.http.HttpClient; import java.net.http.HttpRequest; @@ -282,231 +281,55 @@ void testConstructor_WithCustomExecutorConfiguresHttpClient() throws Exception { @Test @SuppressWarnings("unchecked") - void testInitialize_ServerReturnsErrorJsonRpcResponse() throws Exception { - List versions = - List.of( - ProtocolVersion.VERSION_2025_11_25, - ProtocolVersion.VERSION_2025_06_18, - ProtocolVersion.VERSION_2025_03_26, - ProtocolVersion.VERSION_2024_11_05); - for (ProtocolVersion version : versions) { - HttpClient localMockClient = mock(HttpClient.class); - HttpMcpTransport versionedTransport = - new HttpMcpTransport( - "https://test-mcp-service.com", - Collections.emptyMap(), - null, - version, - localMockClient, - null); - - HttpResponse mockInitResponse = mock(HttpResponse.class); - when(mockInitResponse.statusCode()).thenReturn(200); - when(mockInitResponse.body()) - .thenReturn( - "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"error\":{\"code\":-32603,\"message\":\"Internal" - + " error\"}}"); - - when(localMockClient.sendAsync( - any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenReturn(CompletableFuture.completedFuture(mockInitResponse)); - - CompletableFuture future = - versionedTransport.listTools("", Collections.emptyMap()); - java.util.concurrent.ExecutionException ex = - org.junit.jupiter.api.Assertions.assertThrows( - java.util.concurrent.ExecutionException.class, future::get); - assertTrue(ex.getCause() instanceof McpException); - assertTrue(ex.getCause().getMessage().contains("MCP Error")); - } - } - - @Test - @SuppressWarnings("unchecked") - void testListTools_Non200Response_ThrowsException() { - List versions = - List.of( - ProtocolVersion.VERSION_2025_11_25, - ProtocolVersion.VERSION_2025_06_18, - ProtocolVersion.VERSION_2025_03_26, - ProtocolVersion.VERSION_2024_11_05); - for (ProtocolVersion version : versions) { - HttpClient localMockClient = mock(HttpClient.class); - HttpMcpTransport versionedTransport = - new HttpMcpTransport( - "https://test-mcp-service.com", - Collections.emptyMap(), - null, - version, - localMockClient, - null); - - HttpResponse mockInitResponse = mock(HttpResponse.class); - when(mockInitResponse.statusCode()).thenReturn(200); - when(mockInitResponse.body()) - .thenReturn( - "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"" - + version.getValue() - + "\"}}"); - java.net.http.HttpHeaders mockHeaders = - java.net.http.HttpHeaders.of( - Map.of("Mcp-Session-Id", List.of("test-session-123")), (k, v) -> true); - when(mockInitResponse.headers()).thenReturn(mockHeaders); - - HttpResponse mockInitializedResponse = mock(HttpResponse.class); - when(mockInitializedResponse.statusCode()).thenReturn(200); - when(mockInitializedResponse.body()).thenReturn(""); - - HttpResponse mockErrorResponse = mock(HttpResponse.class); - when(mockErrorResponse.statusCode()).thenReturn(500); - when(mockErrorResponse.body()).thenReturn("Internal Server Error"); - - when(localMockClient.sendAsync( - any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenReturn(CompletableFuture.completedFuture(mockInitResponse)) - .thenReturn(CompletableFuture.completedFuture(mockInitializedResponse)) - .thenReturn(CompletableFuture.completedFuture(mockErrorResponse)); - - Exception ex = - org.junit.jupiter.api.Assertions.assertThrows( - Exception.class, - () -> versionedTransport.listTools("", Collections.emptyMap()).get()); - assertTrue(ex.getCause().getMessage().contains("Status: 500")); - } - } - - @Test - @SuppressWarnings("unchecked") - void testListTools_JsonRpcError_ThrowsException() { - List versions = - List.of( - ProtocolVersion.VERSION_2025_11_25, - ProtocolVersion.VERSION_2025_06_18, - ProtocolVersion.VERSION_2025_03_26, - ProtocolVersion.VERSION_2024_11_05); - for (ProtocolVersion version : versions) { - HttpClient localMockClient = mock(HttpClient.class); - HttpMcpTransport versionedTransport = - new HttpMcpTransport( - "https://test-mcp-service.com", - Collections.emptyMap(), - null, - version, - localMockClient, - null); - - HttpResponse mockInitResponse = mock(HttpResponse.class); - when(mockInitResponse.statusCode()).thenReturn(200); - when(mockInitResponse.body()) - .thenReturn( - "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"" - + version.getValue() - + "\"}}"); - java.net.http.HttpHeaders mockHeaders = - java.net.http.HttpHeaders.of( - Map.of("Mcp-Session-Id", List.of("test-session-123")), (k, v) -> true); - when(mockInitResponse.headers()).thenReturn(mockHeaders); - - HttpResponse mockInitializedResponse = mock(HttpResponse.class); - when(mockInitializedResponse.statusCode()).thenReturn(200); - when(mockInitializedResponse.body()).thenReturn(""); - - HttpResponse mockErrorResponse = mock(HttpResponse.class); - when(mockErrorResponse.statusCode()).thenReturn(200); - when(mockErrorResponse.body()) - .thenReturn( - "{\"jsonrpc\":\"2.0\",\"id\":\"2\",\"error\":{\"code\":-1,\"message\":\"Custom" - + " error\"}}"); - - when(localMockClient.sendAsync( - any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenReturn(CompletableFuture.completedFuture(mockInitResponse)) - .thenReturn(CompletableFuture.completedFuture(mockInitializedResponse)) - .thenReturn(CompletableFuture.completedFuture(mockErrorResponse)); + void testListTools_WithHttpUrlAndMetadata_LogsWarning() throws Exception { + HttpMcpTransport httpTransport = + new HttpMcpTransport("http://test-mcp-service.com", mockClient); + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(200); + when(mockInitResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"2025-11-25\"}}"); - Exception ex = - org.junit.jupiter.api.Assertions.assertThrows( - Exception.class, - () -> versionedTransport.listTools("", Collections.emptyMap()).get()); - assertTrue(ex.getCause().getMessage().contains("Custom error")); - } - } + HttpResponse mockInitializedResponse = mock(HttpResponse.class); + when(mockInitializedResponse.statusCode()).thenReturn(200); + when(mockInitializedResponse.body()).thenReturn(""); - @Test - @SuppressWarnings("unchecked") - void testInitialize_VersionMismatch_ThrowsException() { - List versions = - List.of( - ProtocolVersion.VERSION_2025_11_25, - ProtocolVersion.VERSION_2025_06_18, - ProtocolVersion.VERSION_2025_03_26, - ProtocolVersion.VERSION_2024_11_05); - for (ProtocolVersion version : versions) { - HttpClient localMockClient = mock(HttpClient.class); - HttpMcpTransport versionedTransport = - new HttpMcpTransport( - "https://test-mcp-service.com", - Collections.emptyMap(), - null, - version, - localMockClient, - null); + HttpResponse mockListResponse = mock(HttpResponse.class); + when(mockListResponse.statusCode()).thenReturn(200); + when(mockListResponse.body()) + .thenReturn("{\"jsonrpc\":\"2.0\",\"id\":\"2\",\"result\":{\"tools\":[]}}"); - HttpResponse mockInitResponse = mock(HttpResponse.class); - when(mockInitResponse.statusCode()).thenReturn(200); - when(mockInitResponse.body()) - .thenReturn( - "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"2000-01-01\"}}"); + when(mockClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockInitResponse)) + .thenReturn(CompletableFuture.completedFuture(mockInitializedResponse)) + .thenReturn(CompletableFuture.completedFuture(mockListResponse)); - when(localMockClient.sendAsync( - any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenReturn(CompletableFuture.completedFuture(mockInitResponse)); + java.util.logging.Logger transportLogger = + java.util.logging.Logger.getLogger(BaseMcpTransport.class.getName()); + java.util.List logRecords = new java.util.ArrayList<>(); + java.util.logging.Handler logHandler = + new java.util.logging.Handler() { + @Override + public void publish(java.util.logging.LogRecord record) { + logRecords.add(record); + } + + @Override + public void flush() {} + + @Override + public void close() throws SecurityException {} + }; + transportLogger.addHandler(logHandler); - Exception ex = - org.junit.jupiter.api.Assertions.assertThrows( - Exception.class, - () -> versionedTransport.listTools("", Collections.emptyMap()).get()); - assertTrue(ex.getCause().getMessage().contains("version mismatch")); + try { + httpTransport.listTools("", Map.of("key", "val")).get(); + } finally { + transportLogger.removeHandler(logHandler); } - } - @Test - @SuppressWarnings("unchecked") - void testInitialize_MissingSessionIdHeader_ThrowsException() { - List sessionVer = List.of(ProtocolVersion.VERSION_2025_03_26); - for (ProtocolVersion version : sessionVer) { - HttpClient localMockClient = mock(HttpClient.class); - HttpMcpTransport versionedTransport = - new HttpMcpTransport( - "https://test-mcp-service.com", - Collections.emptyMap(), - null, - version, - localMockClient, - null); - - HttpResponse mockInitResponse = mock(HttpResponse.class); - when(mockInitResponse.statusCode()).thenReturn(200); - when(mockInitResponse.body()) - .thenReturn( - "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"" - + version.getValue() - + "\"}}"); - java.net.http.HttpHeaders mockHeaders = - java.net.http.HttpHeaders.of(Map.of(), (k, v) -> true); - when(mockInitResponse.headers()).thenReturn(mockHeaders); - - when(localMockClient.sendAsync( - any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenReturn(CompletableFuture.completedFuture(mockInitResponse)); - - Exception ex = - org.junit.jupiter.api.Assertions.assertThrows( - Exception.class, - () -> versionedTransport.listTools("", Collections.emptyMap()).get()); - assertTrue( - ex.getCause().getMessage().contains("Server did not return a Mcp-Session-Id header")); - } + assertFalse(logRecords.isEmpty()); + assertTrue(logRecords.get(0).getMessage().contains("This connection is using HTTP")); } @Test @@ -687,42 +510,6 @@ void testProtocolVersionEnumMethods() { org.junit.jupiter.api.Assertions.assertNull(ProtocolVersion.fromString("invalid-version")); } - @Test - @SuppressWarnings("unchecked") - void testInitialize_Non200Response_ThrowsException() { - List versions = - List.of( - ProtocolVersion.VERSION_2025_11_25, - ProtocolVersion.VERSION_2025_06_18, - ProtocolVersion.VERSION_2025_03_26, - ProtocolVersion.VERSION_2024_11_05); - for (ProtocolVersion version : versions) { - HttpClient localMockClient = mock(HttpClient.class); - HttpMcpTransport versionedTransport = - new HttpMcpTransport( - "https://test-mcp-service.com", - Collections.emptyMap(), - null, - version, - localMockClient, - null); - - HttpResponse mockInitResponse = mock(HttpResponse.class); - when(mockInitResponse.statusCode()).thenReturn(500); - when(mockInitResponse.body()).thenReturn("Init Server Error"); - - when(localMockClient.sendAsync( - any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) - .thenReturn(CompletableFuture.completedFuture(mockInitResponse)); - - Exception ex = - org.junit.jupiter.api.Assertions.assertThrows( - Exception.class, - () -> versionedTransport.listTools("", Collections.emptyMap()).get()); - assertTrue(ex.getCause().getMessage().contains("Init failed: 500")); - } - } - @Test void testClose() throws Exception { transport.close();