From db3de246802b081e8859f9810ebd870935c2bb8c Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Wed, 17 Jun 2026 17:43:03 +0530 Subject: [PATCH 01/20] feat: introduce Transport layer abstraction and HttpMcpTransport --- .../google/cloud/mcp/HttpMcpTransport.java | 284 ++++++++++++++++ .../cloud/mcp/McpToolboxClientBuilder.java | 3 +- .../cloud/mcp/McpToolboxClientImpl.java | 306 +++++------------- .../java/com/google/cloud/mcp/Transport.java | 56 ++++ .../google/cloud/mcp/TransportManifest.java | 42 +++ .../cloud/mcp/HttpMcpTransportTest.java | 181 +++++++++++ 6 files changed, 637 insertions(+), 235 deletions(-) create mode 100644 src/main/java/com/google/cloud/mcp/HttpMcpTransport.java create mode 100644 src/main/java/com/google/cloud/mcp/Transport.java create mode 100644 src/main/java/com/google/cloud/mcp/TransportManifest.java create mode 100644 src/test/java/com/google/cloud/mcp/HttpMcpTransportTest.java diff --git a/src/main/java/com/google/cloud/mcp/HttpMcpTransport.java b/src/main/java/com/google/cloud/mcp/HttpMcpTransport.java new file mode 100644 index 0000000..d2685a7 --- /dev/null +++ b/src/main/java/com/google/cloud/mcp/HttpMcpTransport.java @@ -0,0 +1,284 @@ +/* + * 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; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Logger; + +/** Default HTTP transport implementation using Java 11 HttpClient. */ +public class HttpMcpTransport implements Transport { + + private static final Logger logger = Logger.getLogger(HttpMcpTransport.class.getName()); + private 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 HttpClient httpClient; + private final ObjectMapper objectMapper; + private final String protocolVersion = "2025-11-25"; + private boolean initialized = false; + + /** + * Constructs a new HttpMcpTransport with a base URL. + * + * @param baseUrl The base URL of the remote service. + */ + public HttpMcpTransport(String baseUrl) { + this(baseUrl, HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build()); + } + + /** Package-private constructor for unit testing. */ + HttpMcpTransport(String baseUrl, HttpClient httpClient) { + this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + this.httpClient = httpClient; + this.objectMapper = new ObjectMapper(); + } + + @Override + public String getBaseUrl() { + return this.baseUrl; + } + + private synchronized CompletableFuture ensureInitialized(String authHeader) { + if (initialized) return CompletableFuture.completedFuture(null); + 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, "mcp-toolbox-sdk-java")); + String body = objectMapper.writeValueAsString(initReq); + HttpRequest.Builder req = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl)) + .header("Content-Type", "application/json") + .POST(HttpRequest.BodyPublishers.ofString(body)); + if (authHeader != null) req.header("Authorization", authHeader); + + return httpClient + .sendAsync(req.build(), HttpResponse.BodyHandlers.ofString()) + .thenCompose( + res -> { + if (res.statusCode() != 200) { + return CompletableFuture.failedFuture( + new RuntimeException("Init failed: " + res.statusCode() + " " + res.body())); + } + try { + JsonRpc.Notification notif = + new JsonRpc.Notification("notifications/initialized", Map.of()); + String notifBody = objectMapper.writeValueAsString(notif); + HttpRequest.Builder nReq = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl)) + .header("Content-Type", "application/json") + .header("MCP-Protocol-Version", protocolVersion) + .POST(HttpRequest.BodyPublishers.ofString(notifBody)); + if (authHeader != null) nReq.header("Authorization", authHeader); + + return httpClient + .sendAsync(nReq.build(), HttpResponse.BodyHandlers.ofString()) + .thenAccept( + nRes -> { + initialized = true; + }); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + }); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + } + + @Override + public CompletableFuture listTools( + String toolsetName, Map headers) { + String authHeader = headers.get("Authorization"); + if (this.baseUrl.toLowerCase(java.util.Locale.ROOT).startsWith("http://") + && !headers.isEmpty()) { + logger.warning(HTTP_WARNING); + } + return ensureInitialized(authHeader) + .thenCompose( + v -> { + String path = toolsetName != null && !toolsetName.isEmpty() ? "/" + toolsetName : ""; + String url = baseUrl + path; + try { + JsonRpc.Request listReq = new JsonRpc.Request("tools/list", Map.of()); + String body = objectMapper.writeValueAsString(listReq); + HttpRequest.Builder req = + HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("Content-Type", "application/json") + .header("MCP-Protocol-Version", protocolVersion) + .POST(HttpRequest.BodyPublishers.ofString(body)); + headers.forEach(req::setHeader); + + return httpClient + .sendAsync(req.build(), HttpResponse.BodyHandlers.ofString()) + .thenApply(this::handleListToolsResponse); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + }); + } + + @Override + public CompletableFuture invokeTool( + String toolName, Map arguments, Map headers) { + String authHeader = headers.get("Authorization"); + if (this.baseUrl.toLowerCase(java.util.Locale.ROOT).startsWith("http://") + && !headers.isEmpty()) { + logger.warning(HTTP_WARNING); + } + return ensureInitialized(authHeader) + .thenCompose( + v -> { + try { + JsonRpc.Request invokeReq = + new JsonRpc.Request( + "tools/call", new JsonRpc.CallToolParams(toolName, arguments)); + String requestBody = objectMapper.writeValueAsString(invokeReq); + + HttpRequest.Builder requestBuilder = + HttpRequest.newBuilder() + .uri(URI.create(baseUrl)) + .header("Content-Type", "application/json") + .header("MCP-Protocol-Version", protocolVersion) + .POST(HttpRequest.BodyPublishers.ofString(requestBody)); + + headers.forEach(requestBuilder::setHeader); + + return httpClient + .sendAsync(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()) + .thenApply( + res -> { + if (res.statusCode() != 200) { + throw new RuntimeException( + "Error " + res.statusCode() + ": " + res.body()); + } + return res.body(); + }); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + }); + } + + @Override + public void close() { + // No-op for HttpClient in Java 11 + } + + private TransportManifest handleListToolsResponse(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")) { + throw new RuntimeException("MCP Error: " + root.get("error").toString()); + } + JsonNode result = root.get("result"); + JsonNode toolsNode = result.get("tools"); + + Map toolsMap = new HashMap<>(); + if (toolsNode != null && toolsNode.isArray()) { + for (JsonNode toolNode : toolsNode) { + String name = toolNode.get("name").asText(); + String description = + toolNode.has("description") ? toolNode.get("description").asText() : ""; + + List authRequired = new ArrayList<>(); + JsonNode metaNode = toolNode.get("_meta"); + if (metaNode != null && metaNode.has("toolbox/authInvoke")) { + JsonNode invokeAuthNode = metaNode.get("toolbox/authInvoke"); + if (invokeAuthNode != null && invokeAuthNode.isArray()) { + for (JsonNode src : invokeAuthNode) { + authRequired.add(src.asText()); + } + } + } + + List params = new ArrayList<>(); + JsonNode inputSchema = toolNode.get("inputSchema"); + JsonNode requiredNode = inputSchema != null ? inputSchema.get("required") : null; + Set requiredSet = new HashSet<>(); + if (requiredNode != null && requiredNode.isArray()) { + for (JsonNode req : requiredNode) { + requiredSet.add(req.asText()); + } + } + + JsonNode propertiesNode = inputSchema != null ? inputSchema.get("properties") : null; + if (propertiesNode != null && propertiesNode.isObject()) { + Iterator> fields = propertiesNode.fields(); + while (fields.hasNext()) { + Map.Entry entry = fields.next(); + String paramName = entry.getKey(); + JsonNode propNode = entry.getValue(); + + String paramType = propNode.has("type") ? propNode.get("type").asText() : "string"; + String paramDesc = + propNode.has("description") ? propNode.get("description").asText() : ""; + + List authSources = new ArrayList<>(); + if (metaNode != null && metaNode.has("toolbox/authParam")) { + JsonNode paramAuthNode = metaNode.get("toolbox/authParam").get(paramName); + if (paramAuthNode != null && paramAuthNode.isArray()) { + for (JsonNode src : paramAuthNode) { + authSources.add(src.asText()); + } + } + } + + params.add( + new ToolDefinition.Parameter( + paramName, + paramType, + requiredSet.contains(paramName), + paramDesc, + authSources)); + } + } + + toolsMap.put(name, new ToolDefinition(description, params, authRequired)); + } + } + return new TransportManifest(toolsMap); + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/com/google/cloud/mcp/McpToolboxClientBuilder.java b/src/main/java/com/google/cloud/mcp/McpToolboxClientBuilder.java index 30fdde4..24e4a68 100644 --- a/src/main/java/com/google/cloud/mcp/McpToolboxClientBuilder.java +++ b/src/main/java/com/google/cloud/mcp/McpToolboxClientBuilder.java @@ -82,6 +82,7 @@ public McpToolboxClient build() { resolvedProvider = () -> CompletableFuture.completedFuture(bearerKey); } - return new McpToolboxClientImpl(baseUrl, this.headers, resolvedProvider); + Transport transport = new HttpMcpTransport(baseUrl); + return new McpToolboxClientImpl(transport, this.headers, resolvedProvider); } } diff --git a/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java b/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java index 2293ddf..e57895b 100644 --- a/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java +++ b/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java @@ -41,29 +41,25 @@ public class McpToolboxClientImpl implements McpToolboxClient { private 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 Transport transport; private final Map headers; private final CredentialsProvider credentialsProvider; - private final HttpClient httpClient; private final ObjectMapper objectMapper; - private boolean initialized = false; - private final String protocolVersion = "2025-11-25"; /** * Constructs a new McpToolboxClientImpl. * - * @param baseUrl The base URL of the MCP Toolbox Server. + * @param transport The underlying MCP transport layer. * @param credentialsProvider The provider for authentication headers (optional). */ public McpToolboxClientImpl( - String baseUrl, Map headers, CredentialsProvider credentialsProvider) { - this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + Transport transport, Map headers, CredentialsProvider credentialsProvider) { + this.transport = transport; this.headers = headers != null ? java.util.Collections.unmodifiableMap(new java.util.HashMap<>(headers)) : java.util.Collections.emptyMap(); this.credentialsProvider = credentialsProvider; - this.httpClient = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build(); this.objectMapper = new ObjectMapper(); } @@ -94,8 +90,47 @@ public McpToolboxClientImpl(String baseUrl, CredentialsProvider credentialsProvi * @param apiKey The static API key. */ @Deprecated - public McpToolboxClientImpl(String baseUrl, String apiKey) { - this(baseUrl, Collections.emptyMap(), apiKeyToProvider(apiKey)); + this(new HttpMcpTransport(baseUrl), Collections.emptyMap(), apiKeyToProvider(apiKey)); + } + + /** + * Constructs a new McpToolboxClientImpl with generic headers. + * + * @param baseUrl The base URL of the MCP Toolbox Server. + * @param headers The HTTP headers to include in requests. + */ + @Deprecated + public McpToolboxClientImpl(String baseUrl, Map headers) { + this(new HttpMcpTransport(baseUrl), headers, null); + } + + /** + * Constructs a new McpToolboxClientImpl. + * + * @param baseUrl The base URL of the MCP Toolbox Server. + * @param headers The HTTP headers to include in requests. + * @param credentialsProvider The provider for authentication headers (optional). + */ + @Deprecated + public McpToolboxClientImpl( + String baseUrl, Map headers, CredentialsProvider credentialsProvider) { + this(new HttpMcpTransport(baseUrl), headers, credentialsProvider); + } + + /** + * Deprecated constructor. Use the constructor accepting {@link CredentialsProvider} instead. + */ + @Deprecated + public McpToolboxClientImpl(String baseUrl, CredentialsProvider credentialsProvider) { + this(new HttpMcpTransport(baseUrl), Collections.emptyMap(), credentialsProvider); + } + + /** + * Deprecated constructor. Use the constructor accepting {@link Transport} instead. + */ + @Deprecated + public McpToolboxClientImpl(Transport transport, CredentialsProvider credentialsProvider) { + this(transport, Collections.emptyMap(), credentialsProvider); } private static CredentialsProvider apiKeyToProvider(String apiKey) { @@ -106,66 +141,6 @@ private static CredentialsProvider apiKeyToProvider(String apiKey) { return () -> CompletableFuture.completedFuture(bearerKey); } - private synchronized CompletableFuture ensureInitialized(String authHeader) { - if (initialized) return CompletableFuture.completedFuture(null); - 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, "mcp-toolbox-sdk-java")); - String body = objectMapper.writeValueAsString(initReq); - HttpRequest.Builder req = - HttpRequest.newBuilder() - .uri(URI.create(baseUrl)) - .header("Content-Type", "application/json") - .POST(HttpRequest.BodyPublishers.ofString(body)); - this.headers.forEach( - (k, v) -> { - if (!"Authorization".equalsIgnoreCase(k)) req.setHeader(k, v); - }); - if (authHeader != null) req.setHeader("Authorization", authHeader); - - return httpClient - .sendAsync(req.build(), HttpResponse.BodyHandlers.ofString()) - .thenCompose( - res -> { - if (res.statusCode() != 200) { - return CompletableFuture.failedFuture( - new RuntimeException("Init failed: " + res.statusCode() + " " + res.body())); - } - try { - JsonRpc.Notification notif = - new JsonRpc.Notification("notifications/initialized", Map.of()); - String notifBody = objectMapper.writeValueAsString(notif); - HttpRequest.Builder nReq = - HttpRequest.newBuilder() - .uri(URI.create(baseUrl)) - .header("Content-Type", "application/json") - .header("MCP-Protocol-Version", protocolVersion) - .POST(HttpRequest.BodyPublishers.ofString(notifBody)); - this.headers.forEach( - (k, val) -> { - if (!"Authorization".equalsIgnoreCase(k)) nReq.setHeader(k, val); - }); - if (authHeader != null) nReq.setHeader("Authorization", authHeader); - - return httpClient - .sendAsync(nReq.build(), HttpResponse.BodyHandlers.ofString()) - .thenAccept( - nRes -> { - initialized = true; - }); - } catch (Exception e) { - return CompletableFuture.failedFuture(e); - } - }); - } catch (Exception e) { - return CompletableFuture.failedFuture(e); - } - } @Override public CompletableFuture> listTools() { @@ -176,37 +151,15 @@ public CompletableFuture> listTools() { public CompletableFuture> loadToolset(String toolsetName) { return getAuthorizationHeader() .thenCompose( - authHeader -> - ensureInitialized(authHeader) - .thenCompose( - v -> { - String path = - toolsetName != null && !toolsetName.isEmpty() - ? "/" + toolsetName - : ""; - String url = baseUrl + path; - try { - JsonRpc.Request listReq = new JsonRpc.Request("tools/list", Map.of()); - String body = objectMapper.writeValueAsString(listReq); - HttpRequest.Builder req = - HttpRequest.newBuilder() - .uri(URI.create(url)) - .header("Content-Type", "application/json") - .header("MCP-Protocol-Version", protocolVersion) - .POST(HttpRequest.BodyPublishers.ofString(body)); - this.headers.forEach( - (k, val) -> { - if (!"Authorization".equalsIgnoreCase(k)) req.setHeader(k, val); - }); - if (authHeader != null) req.setHeader("Authorization", authHeader); - - return httpClient - .sendAsync(req.build(), HttpResponse.BodyHandlers.ofString()) - .thenApply(this::handleListToolsResponse); - } catch (Exception e) { - return CompletableFuture.failedFuture(e); - } - })); + authHeader -> { + Map mergedHeaders = new HashMap<>(this.headers); + if (authHeader != null) { + mergedHeaders.put("Authorization", authHeader); + } + return transport + .listTools(toolsetName, mergedHeaders) + .thenApply(TransportManifest::getTools); + }); } @Override @@ -216,7 +169,7 @@ public CompletableFuture> loadToolset( Map> authBinds, boolean strict) { - if (this.baseUrl.toLowerCase(java.util.Locale.ROOT).startsWith("http://") + if (this.transport.getBaseUrl().toLowerCase(java.util.Locale.ROOT).startsWith("http://") && authBinds != null && !authBinds.isEmpty()) { logger.warning(HTTP_WARNING); @@ -261,7 +214,7 @@ public CompletableFuture loadTool(String toolName) { @Override public CompletableFuture loadTool( String toolName, Map authTokenGetters) { - if (this.baseUrl.toLowerCase(java.util.Locale.ROOT).startsWith("http://") + if (this.transport.getBaseUrl().toLowerCase(java.util.Locale.ROOT).startsWith("http://") && authTokenGetters != null && !authTokenGetters.isEmpty()) { logger.warning(HTTP_WARNING); @@ -288,7 +241,7 @@ public CompletableFuture invokeTool(String toolName, Map invokeTool( String toolName, Map arguments, Map extraHeaders) { - if (this.baseUrl.toLowerCase(java.util.Locale.ROOT).startsWith("http://") + if (this.transport.getBaseUrl().toLowerCase(java.util.Locale.ROOT).startsWith("http://") && extraHeaders != null && !extraHeaders.isEmpty()) { logger.warning(HTTP_WARNING); @@ -312,46 +265,20 @@ public CompletableFuture invokeTool( finalAuthHeader = adcHeader; } - final String reqAuth = finalAuthHeader; - - return ensureInitialized(reqAuth) - .thenCompose( - v -> { - try { - JsonRpc.Request invokeReq = - new JsonRpc.Request( - "tools/call", new JsonRpc.CallToolParams(toolName, arguments)); - String requestBody = objectMapper.writeValueAsString(invokeReq); - - HttpRequest.Builder requestBuilder = - HttpRequest.newBuilder() - .uri(URI.create(baseUrl)) - .header("Content-Type", "application/json") - .header("MCP-Protocol-Version", protocolVersion) - .POST(HttpRequest.BodyPublishers.ofString(requestBody)); - - this.headers.forEach( - (k, val) -> { - if (!"Authorization".equalsIgnoreCase(k)) - requestBuilder.setHeader(k, val); - }); - extraHeaders.forEach( - (k, val) -> { - if (!"Authorization".equalsIgnoreCase(k)) - requestBuilder.setHeader(k, val); - }); - if (reqAuth != null) { - requestBuilder.setHeader("Authorization", reqAuth); - } - - return httpClient - .sendAsync( - requestBuilder.build(), HttpResponse.BodyHandlers.ofString()) - .thenApply(response -> handleInvokeResponse(response, toolName)); - } catch (Exception e) { - return CompletableFuture.failedFuture(e); - } - }); + Map mergedHeaders = new HashMap<>(this.headers); + extraHeaders.forEach( + (k, val) -> { + if (!"Authorization".equalsIgnoreCase(k)) { + mergedHeaders.put(k, val); + } + }); + if (finalAuthHeader != null) { + mergedHeaders.put("Authorization", finalAuthHeader); + } + + return transport + .invokeTool(toolName, arguments, mergedHeaders) + .thenApply(response -> handleInvokeResponse(response, toolName)); } catch (Exception e) { return CompletableFuture.failedFuture(e); @@ -371,96 +298,7 @@ private CompletableFuture getAuthorizationHeader() { return CompletableFuture.completedFuture(null); } - private Map handleListToolsResponse(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")) { - throw new RuntimeException("MCP Error: " + root.get("error").toString()); - } - JsonNode result = root.get("result"); - JsonNode toolsNode = result.get("tools"); - - Map toolsMap = new HashMap<>(); - if (toolsNode != null && toolsNode.isArray()) { - for (JsonNode toolNode : toolsNode) { - String name = toolNode.get("name").asText(); - String description = - toolNode.has("description") ? toolNode.get("description").asText() : ""; - - List authRequired = new ArrayList<>(); - JsonNode metaNode = toolNode.get("_meta"); - if (metaNode != null && metaNode.has("toolbox/authInvoke")) { - JsonNode invokeAuthNode = metaNode.get("toolbox/authInvoke"); - if (invokeAuthNode != null && invokeAuthNode.isArray()) { - for (JsonNode src : invokeAuthNode) { - authRequired.add(src.asText()); - } - } - } - - List params = new ArrayList<>(); - JsonNode inputSchema = toolNode.get("inputSchema"); - JsonNode requiredNode = inputSchema != null ? inputSchema.get("required") : null; - Set requiredSet = new HashSet<>(); - if (requiredNode != null && requiredNode.isArray()) { - for (JsonNode req : requiredNode) { - requiredSet.add(req.asText()); - } - } - - JsonNode propertiesNode = inputSchema != null ? inputSchema.get("properties") : null; - if (propertiesNode != null && propertiesNode.isObject()) { - Iterator> fields = propertiesNode.fields(); - while (fields.hasNext()) { - Map.Entry entry = fields.next(); - String paramName = entry.getKey(); - JsonNode propNode = entry.getValue(); - - String paramType = propNode.has("type") ? propNode.get("type").asText() : "string"; - String paramDesc = - propNode.has("description") ? propNode.get("description").asText() : ""; - - List authSources = new ArrayList<>(); - // Extract from _meta if exists - if (metaNode != null && metaNode.has("toolbox/authParam")) { - JsonNode paramAuthNode = metaNode.get("toolbox/authParam").get(paramName); - if (paramAuthNode != null && paramAuthNode.isArray()) { - for (JsonNode src : paramAuthNode) { - authSources.add(src.asText()); - } - } - } - - params.add( - new ToolDefinition.Parameter( - paramName, - paramType, - requiredSet.contains(paramName), - paramDesc, - authSources)); - } - } - - toolsMap.put(name, new ToolDefinition(description, params, authRequired)); - } - } - return toolsMap; - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - private ToolResult handleInvokeResponse(HttpResponse response, String toolName) { - String body = response.body(); - if (response.statusCode() != 200) { - return new ToolResult( - java.util.List.of( - new ToolResult.Content("text", "Error " + response.statusCode() + ": " + body)), - true); - } + private ToolResult handleInvokeResponse(String body, String toolName) { try { JsonNode root = objectMapper.readTree(body); if (root.has("error")) { diff --git a/src/main/java/com/google/cloud/mcp/Transport.java b/src/main/java/com/google/cloud/mcp/Transport.java new file mode 100644 index 0000000..6c90a90 --- /dev/null +++ b/src/main/java/com/google/cloud/mcp/Transport.java @@ -0,0 +1,56 @@ +/* + * 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; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** + * Defines the contract for an MCP transport layer that manages protocol-level formatting and + * network communication. + */ +public interface Transport { + /** + * Returns the base URL of the remote service. + * + * @return The base URL string. + */ + String getBaseUrl(); + + /** + * Asynchronously fetches available tools from the server. + * + * @param toolsetName The name of the toolset to load (optional). + * @param headers Extra HTTP headers to include in the request. + * @return A CompletableFuture containing the raw DTO manifest. + */ + CompletableFuture listTools(String toolsetName, Map headers); + + /** + * Asynchronously invokes a tool on the server. + * + * @param toolName The name of the tool to invoke. + * @param arguments The arguments to pass to the tool. + * @param headers Extra HTTP headers to include in the request. + * @return A CompletableFuture containing the raw JSON string result of the tool execution. + */ + CompletableFuture invokeTool( + String toolName, Map arguments, Map headers); + + /** Closes any underlying network connections/resources. */ + void close(); +} diff --git a/src/main/java/com/google/cloud/mcp/TransportManifest.java b/src/main/java/com/google/cloud/mcp/TransportManifest.java new file mode 100644 index 0000000..e294afa --- /dev/null +++ b/src/main/java/com/google/cloud/mcp/TransportManifest.java @@ -0,0 +1,42 @@ +/* + * 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; + +import java.util.Map; + +/** Represents the raw tools manifest returned by the transport. */ +public final class TransportManifest { + private final Map tools; + + /** + * Constructs a new TransportManifest with a map of tool definitions. + * + * @param tools Map of tool name to definition. + */ + public TransportManifest(Map tools) { + this.tools = tools; + } + + /** + * Returns the map of tools in the manifest. + * + * @return The tools map. + */ + public Map getTools() { + return tools; + } +} diff --git a/src/test/java/com/google/cloud/mcp/HttpMcpTransportTest.java b/src/test/java/com/google/cloud/mcp/HttpMcpTransportTest.java new file mode 100644 index 0000000..1daeb53 --- /dev/null +++ b/src/test/java/com/google/cloud/mcp/HttpMcpTransportTest.java @@ -0,0 +1,181 @@ +/* + * 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; + +import static org.junit.jupiter.api.Assertions.assertEquals; +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.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class HttpMcpTransportTest { + + private HttpClient mockClient; + private HttpMcpTransport transport; + + @BeforeEach + @SuppressWarnings("unchecked") + void setUp() { + mockClient = mock(HttpClient.class); + transport = new HttpMcpTransport("https://test-mcp-service.com", mockClient); + } + + @Test + @SuppressWarnings("unchecked") + void testListTools_PerformsHandshakeAndFetchesTools() throws Exception { + // 1. Mock response for 'initialize' + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(200); + when(mockInitResponse.body()) + .thenReturn("{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"2025-11-25\"}}"); + + // 2. Mock response for 'notifications/initialized' + HttpResponse mockInitializedResponse = mock(HttpResponse.class); + when(mockInitializedResponse.statusCode()).thenReturn(200); + when(mockInitializedResponse.body()).thenReturn(""); + + // 3. Mock response for 'tools/list' + HttpResponse mockListResponse = mock(HttpResponse.class); + when(mockListResponse.statusCode()).thenReturn(200); + when(mockListResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"2\",\"result\":{\"tools\":[{\"name\":\"test-tool\"," + + "\"description\":\"A test tool\",\"inputSchema\":{\"type\":\"object\"," + + "\"properties\":{\"param1\":{\"type\":\"string\",\"description\":\"param desc\"}}," + + "\"required\":[\"param1\"]},\"_meta\":{\"toolbox/authInvoke\":[\"gcp\"]}}]}}"); + + CompletableFuture> initFuture = CompletableFuture.completedFuture(mockInitResponse); + CompletableFuture> initializedFuture = CompletableFuture.completedFuture(mockInitializedResponse); + CompletableFuture> listFuture = CompletableFuture.completedFuture(mockListResponse); + + // Set up mock calls sequentially with type hint + when(mockClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(initFuture) + .thenReturn(initializedFuture) + .thenReturn(listFuture); + + CompletableFuture futureManifest = + transport.listTools("", Collections.emptyMap()); + TransportManifest manifest = futureManifest.get(); + + assertNotNull(manifest); + assertEquals(1, manifest.getTools().size()); + assertTrue(manifest.getTools().containsKey("test-tool")); + ToolDefinition def = manifest.getTools().get("test-tool"); + assertEquals("A test tool", def.description()); + assertEquals(1, def.parameters().size()); + assertEquals("param1", def.parameters().get(0).name()); + assertTrue(def.parameters().get(0).required()); + } + + @Test + @SuppressWarnings("unchecked") + void testInvokeTool_PerformsHandshakeAndExecutesCall() throws Exception { + // 1. Mock response for 'initialize' + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(200); + when(mockInitResponse.body()) + .thenReturn("{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"2025-11-25\"}}"); + + // 2. Mock response for 'notifications/initialized' + HttpResponse mockInitializedResponse = mock(HttpResponse.class); + when(mockInitializedResponse.statusCode()).thenReturn(200); + when(mockInitializedResponse.body()).thenReturn(""); + + // 3. Mock response for 'tools/call' + 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\"}]}}"); + + CompletableFuture> initFuture = CompletableFuture.completedFuture(mockInitResponse); + CompletableFuture> initializedFuture = CompletableFuture.completedFuture(mockInitializedResponse); + CompletableFuture> invokeFuture = CompletableFuture.completedFuture(mockInvokeResponse); + + when(mockClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(initFuture) + .thenReturn(initializedFuture) + .thenReturn(invokeFuture); + + CompletableFuture futureResult = + transport.invokeTool("test-tool", Map.of("param1", "value1"), Collections.emptyMap()); + String body = futureResult.get(); + + assertNotNull(body); + assertTrue(body.contains("success")); + } + + @Test + @SuppressWarnings("unchecked") + void testSubsequentCalls_DoNotReinitialize() throws Exception { + // 1. Mock response for 'initialize' + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(200); + when(mockInitResponse.body()) + .thenReturn("{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"2025-11-25\"}}"); + + // 2. Mock response for 'notifications/initialized' + HttpResponse mockInitializedResponse = mock(HttpResponse.class); + when(mockInitializedResponse.statusCode()).thenReturn(200); + when(mockInitializedResponse.body()).thenReturn(""); + + // 3. Mock response for first 'tools/list' + HttpResponse mockListResponse1 = mock(HttpResponse.class); + when(mockListResponse1.statusCode()).thenReturn(200); + when(mockListResponse1.body()) + .thenReturn("{\"jsonrpc\":\"2.0\",\"id\":\"2\",\"result\":{\"tools\":[]}}"); + + // 4. Mock response for second 'tools/list' + HttpResponse mockListResponse2 = mock(HttpResponse.class); + when(mockListResponse2.statusCode()).thenReturn(200); + when(mockListResponse2.body()) + .thenReturn("{\"jsonrpc\":\"2.0\",\"id\":\"3\",\"result\":{\"tools\":[]}}"); + + CompletableFuture> initFuture = CompletableFuture.completedFuture(mockInitResponse); + CompletableFuture> initializedFuture = CompletableFuture.completedFuture(mockInitializedResponse); + CompletableFuture> listFuture1 = CompletableFuture.completedFuture(mockListResponse1); + CompletableFuture> listFuture2 = CompletableFuture.completedFuture(mockListResponse2); + + // Set up sequential answers + when(mockClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(initFuture) + .thenReturn(initializedFuture) + .thenReturn(listFuture1) + .thenReturn(listFuture2); + + // First call lists tools (performs handshake + lists) + transport.listTools("", Collections.emptyMap()).get(); + + // Second call lists tools (should only list tools directly) + transport.listTools("", Collections.emptyMap()).get(); + + // Total calls to sendAsync should be 4 (1: init, 2: initialized, 3: list1, 4: list2) + verify(mockClient, times(4)).sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)); + } +} From bbb3d13c6229e0dbbaa6bfaff92e9b8ae827c790 Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Wed, 17 Jun 2026 17:45:12 +0530 Subject: [PATCH 02/20] style: format files using google-java-format --- .../cloud/mcp/McpToolboxClientImpl.java | 8 --- .../cloud/mcp/HttpMcpTransportTest.java | 52 ++++++++++++------- 2 files changed, 34 insertions(+), 26 deletions(-) diff --git a/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java b/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java index e57895b..acc816a 100644 --- a/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java +++ b/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java @@ -18,17 +18,9 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; -import java.time.Duration; -import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; -import java.util.Iterator; -import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; diff --git a/src/test/java/com/google/cloud/mcp/HttpMcpTransportTest.java b/src/test/java/com/google/cloud/mcp/HttpMcpTransportTest.java index 1daeb53..aa3850e 100644 --- a/src/test/java/com/google/cloud/mcp/HttpMcpTransportTest.java +++ b/src/test/java/com/google/cloud/mcp/HttpMcpTransportTest.java @@ -53,7 +53,8 @@ void testListTools_PerformsHandshakeAndFetchesTools() throws Exception { HttpResponse mockInitResponse = mock(HttpResponse.class); when(mockInitResponse.statusCode()).thenReturn(200); when(mockInitResponse.body()) - .thenReturn("{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"2025-11-25\"}}"); + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"2025-11-25\"}}"); // 2. Mock response for 'notifications/initialized' HttpResponse mockInitializedResponse = mock(HttpResponse.class); @@ -65,14 +66,18 @@ void testListTools_PerformsHandshakeAndFetchesTools() throws Exception { when(mockListResponse.statusCode()).thenReturn(200); when(mockListResponse.body()) .thenReturn( - "{\"jsonrpc\":\"2.0\",\"id\":\"2\",\"result\":{\"tools\":[{\"name\":\"test-tool\"," - + "\"description\":\"A test tool\",\"inputSchema\":{\"type\":\"object\"," - + "\"properties\":{\"param1\":{\"type\":\"string\",\"description\":\"param desc\"}}," + "{\"jsonrpc\":\"2.0\",\"id\":\"2\",\"result\":{\"tools\":[{\"name\":\"test-tool\",\"description\":\"A" + + " test" + + " tool\",\"inputSchema\":{\"type\":\"object\",\"properties\":{\"param1\":{\"type\":\"string\",\"description\":\"param" + + " desc\"}}," + "\"required\":[\"param1\"]},\"_meta\":{\"toolbox/authInvoke\":[\"gcp\"]}}]}}"); - CompletableFuture> initFuture = CompletableFuture.completedFuture(mockInitResponse); - CompletableFuture> initializedFuture = CompletableFuture.completedFuture(mockInitializedResponse); - CompletableFuture> listFuture = CompletableFuture.completedFuture(mockListResponse); + CompletableFuture> initFuture = + CompletableFuture.completedFuture(mockInitResponse); + CompletableFuture> initializedFuture = + CompletableFuture.completedFuture(mockInitializedResponse); + CompletableFuture> listFuture = + CompletableFuture.completedFuture(mockListResponse); // Set up mock calls sequentially with type hint when(mockClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) @@ -101,7 +106,8 @@ void testInvokeTool_PerformsHandshakeAndExecutesCall() throws Exception { HttpResponse mockInitResponse = mock(HttpResponse.class); when(mockInitResponse.statusCode()).thenReturn(200); when(mockInitResponse.body()) - .thenReturn("{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"2025-11-25\"}}"); + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"2025-11-25\"}}"); // 2. Mock response for 'notifications/initialized' HttpResponse mockInitializedResponse = mock(HttpResponse.class); @@ -112,11 +118,15 @@ void testInvokeTool_PerformsHandshakeAndExecutesCall() throws Exception { 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\"}]}}"); + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"3\",\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"success\"}]}}"); - CompletableFuture> initFuture = CompletableFuture.completedFuture(mockInitResponse); - CompletableFuture> initializedFuture = CompletableFuture.completedFuture(mockInitializedResponse); - CompletableFuture> invokeFuture = CompletableFuture.completedFuture(mockInvokeResponse); + CompletableFuture> initFuture = + CompletableFuture.completedFuture(mockInitResponse); + CompletableFuture> initializedFuture = + CompletableFuture.completedFuture(mockInitializedResponse); + CompletableFuture> invokeFuture = + CompletableFuture.completedFuture(mockInvokeResponse); when(mockClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) .thenReturn(initFuture) @@ -138,7 +148,8 @@ void testSubsequentCalls_DoNotReinitialize() throws Exception { HttpResponse mockInitResponse = mock(HttpResponse.class); when(mockInitResponse.statusCode()).thenReturn(200); when(mockInitResponse.body()) - .thenReturn("{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"2025-11-25\"}}"); + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"2025-11-25\"}}"); // 2. Mock response for 'notifications/initialized' HttpResponse mockInitializedResponse = mock(HttpResponse.class); @@ -157,10 +168,14 @@ void testSubsequentCalls_DoNotReinitialize() throws Exception { when(mockListResponse2.body()) .thenReturn("{\"jsonrpc\":\"2.0\",\"id\":\"3\",\"result\":{\"tools\":[]}}"); - CompletableFuture> initFuture = CompletableFuture.completedFuture(mockInitResponse); - CompletableFuture> initializedFuture = CompletableFuture.completedFuture(mockInitializedResponse); - CompletableFuture> listFuture1 = CompletableFuture.completedFuture(mockListResponse1); - CompletableFuture> listFuture2 = CompletableFuture.completedFuture(mockListResponse2); + CompletableFuture> initFuture = + CompletableFuture.completedFuture(mockInitResponse); + CompletableFuture> initializedFuture = + CompletableFuture.completedFuture(mockInitializedResponse); + CompletableFuture> listFuture1 = + CompletableFuture.completedFuture(mockListResponse1); + CompletableFuture> listFuture2 = + CompletableFuture.completedFuture(mockListResponse2); // Set up sequential answers when(mockClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) @@ -176,6 +191,7 @@ void testSubsequentCalls_DoNotReinitialize() throws Exception { transport.listTools("", Collections.emptyMap()).get(); // Total calls to sendAsync should be 4 (1: init, 2: initialized, 3: list1, 4: list2) - verify(mockClient, times(4)).sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)); + verify(mockClient, times(4)) + .sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class)); } } From ebe8dca33013eb0ba6d0645b329a04cef76e7f35 Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Wed, 17 Jun 2026 17:50:22 +0530 Subject: [PATCH 03/20] fix: pass TransportResponse containing status code to client to resolve integration test failures --- .../google/cloud/mcp/HttpMcpTransport.java | 11 +--- .../cloud/mcp/McpToolboxClientImpl.java | 9 +++- .../java/com/google/cloud/mcp/Transport.java | 4 +- .../google/cloud/mcp/TransportResponse.java | 52 +++++++++++++++++++ .../cloud/mcp/HttpMcpTransportTest.java | 9 ++-- 5 files changed, 69 insertions(+), 16 deletions(-) create mode 100644 src/main/java/com/google/cloud/mcp/TransportResponse.java diff --git a/src/main/java/com/google/cloud/mcp/HttpMcpTransport.java b/src/main/java/com/google/cloud/mcp/HttpMcpTransport.java index d2685a7..a83d338 100644 --- a/src/main/java/com/google/cloud/mcp/HttpMcpTransport.java +++ b/src/main/java/com/google/cloud/mcp/HttpMcpTransport.java @@ -155,7 +155,7 @@ public CompletableFuture listTools( } @Override - public CompletableFuture invokeTool( + public CompletableFuture invokeTool( String toolName, Map arguments, Map headers) { String authHeader = headers.get("Authorization"); if (this.baseUrl.toLowerCase(java.util.Locale.ROOT).startsWith("http://") @@ -182,14 +182,7 @@ public CompletableFuture invokeTool( return httpClient .sendAsync(requestBuilder.build(), HttpResponse.BodyHandlers.ofString()) - .thenApply( - res -> { - if (res.statusCode() != 200) { - throw new RuntimeException( - "Error " + res.statusCode() + ": " + res.body()); - } - return res.body(); - }); + .thenApply(res -> new TransportResponse(res.statusCode(), res.body())); } catch (Exception e) { return CompletableFuture.failedFuture(e); } diff --git a/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java b/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java index acc816a..3e133a7 100644 --- a/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java +++ b/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java @@ -290,7 +290,14 @@ private CompletableFuture getAuthorizationHeader() { return CompletableFuture.completedFuture(null); } - private ToolResult handleInvokeResponse(String body, String toolName) { + private ToolResult handleInvokeResponse(TransportResponse response, String toolName) { + String body = response.getBody(); + if (response.getStatusCode() != 200) { + return new ToolResult( + java.util.List.of( + new ToolResult.Content("text", "Error " + response.getStatusCode() + ": " + body)), + true); + } try { JsonNode root = objectMapper.readTree(body); if (root.has("error")) { diff --git a/src/main/java/com/google/cloud/mcp/Transport.java b/src/main/java/com/google/cloud/mcp/Transport.java index 6c90a90..e896fc2 100644 --- a/src/main/java/com/google/cloud/mcp/Transport.java +++ b/src/main/java/com/google/cloud/mcp/Transport.java @@ -46,9 +46,9 @@ public interface Transport { * @param toolName The name of the tool to invoke. * @param arguments The arguments to pass to the tool. * @param headers Extra HTTP headers to include in the request. - * @return A CompletableFuture containing the raw JSON string result of the tool execution. + * @return A CompletableFuture containing the raw TransportResponse result of the tool execution. */ - CompletableFuture invokeTool( + CompletableFuture invokeTool( String toolName, Map arguments, Map headers); /** Closes any underlying network connections/resources. */ diff --git a/src/main/java/com/google/cloud/mcp/TransportResponse.java b/src/main/java/com/google/cloud/mcp/TransportResponse.java new file mode 100644 index 0000000..4532b69 --- /dev/null +++ b/src/main/java/com/google/cloud/mcp/TransportResponse.java @@ -0,0 +1,52 @@ +/* + * 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; + +/** Represents a raw transport response containing status code and response body. */ +public final class TransportResponse { + private final int statusCode; + private final String body; + + /** + * Constructs a new TransportResponse. + * + * @param statusCode The HTTP status code. + * @param body The response body. + */ + public TransportResponse(int statusCode, String body) { + this.statusCode = statusCode; + this.body = body; + } + + /** + * Returns the status code. + * + * @return The status code. + */ + public int getStatusCode() { + return statusCode; + } + + /** + * Returns the response body. + * + * @return The response body. + */ + public String getBody() { + return body; + } +} diff --git a/src/test/java/com/google/cloud/mcp/HttpMcpTransportTest.java b/src/test/java/com/google/cloud/mcp/HttpMcpTransportTest.java index aa3850e..f8ab3bf 100644 --- a/src/test/java/com/google/cloud/mcp/HttpMcpTransportTest.java +++ b/src/test/java/com/google/cloud/mcp/HttpMcpTransportTest.java @@ -133,12 +133,13 @@ void testInvokeTool_PerformsHandshakeAndExecutesCall() throws Exception { .thenReturn(initializedFuture) .thenReturn(invokeFuture); - CompletableFuture futureResult = + CompletableFuture futureResult = transport.invokeTool("test-tool", Map.of("param1", "value1"), Collections.emptyMap()); - String body = futureResult.get(); + TransportResponse response = futureResult.get(); - assertNotNull(body); - assertTrue(body.contains("success")); + assertNotNull(response); + assertEquals(200, response.getStatusCode()); + assertTrue(response.getBody().contains("success")); } @Test From 1501cfd12479f070e90a35eec81153d7989a96df Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Sat, 20 Jun 2026 20:35:35 +0530 Subject: [PATCH 04/20] style: run google-java-format to fix format violations --- .../java/com/google/cloud/mcp/McpToolboxClientImpl.java | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java b/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java index 3e133a7..7dbc486 100644 --- a/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java +++ b/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java @@ -109,17 +109,13 @@ public McpToolboxClientImpl( this(new HttpMcpTransport(baseUrl), headers, credentialsProvider); } - /** - * Deprecated constructor. Use the constructor accepting {@link CredentialsProvider} instead. - */ + /** Deprecated constructor. Use the constructor accepting {@link CredentialsProvider} instead. */ @Deprecated public McpToolboxClientImpl(String baseUrl, CredentialsProvider credentialsProvider) { this(new HttpMcpTransport(baseUrl), Collections.emptyMap(), credentialsProvider); } - /** - * Deprecated constructor. Use the constructor accepting {@link Transport} instead. - */ + /** Deprecated constructor. Use the constructor accepting {@link Transport} instead. */ @Deprecated public McpToolboxClientImpl(Transport transport, CredentialsProvider credentialsProvider) { this(transport, Collections.emptyMap(), credentialsProvider); @@ -133,7 +129,6 @@ private static CredentialsProvider apiKeyToProvider(String apiKey) { return () -> CompletableFuture.completedFuture(bearerKey); } - @Override public CompletableFuture> listTools() { return loadToolset(""); From eeb84e54485c8c6d02a3fa25792053d50b55b9cc Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Mon, 22 Jun 2026 14:20:16 +0530 Subject: [PATCH 05/20] fix: resolve merge conflicts and update unit tests for transport abstraction --- .../google/cloud/mcp/HttpMcpTransport.java | 39 ++++++++-- .../cloud/mcp/McpToolboxClientBuilder.java | 2 +- .../cloud/mcp/McpToolboxClientImpl.java | 20 +---- .../mcp/McpToolboxClientBuilderTest.java | 8 +- .../mcp/McpToolboxClientImplErrorsTest.java | 9 +-- .../mcp/McpToolboxClientImplHeadersTest.java | 30 +++++--- .../mcp/McpToolboxClientImplJsonRpcTest.java | 9 +-- .../cloud/mcp/McpToolboxClientImplTest.java | 77 ++++++++----------- 8 files changed, 91 insertions(+), 103 deletions(-) diff --git a/src/main/java/com/google/cloud/mcp/HttpMcpTransport.java b/src/main/java/com/google/cloud/mcp/HttpMcpTransport.java index a83d338..f884870 100644 --- a/src/main/java/com/google/cloud/mcp/HttpMcpTransport.java +++ b/src/main/java/com/google/cloud/mcp/HttpMcpTransport.java @@ -42,6 +42,7 @@ public class HttpMcpTransport implements Transport { + " communication is sent over HTTPS."; private final String baseUrl; + private final Map clientHeaders; private final HttpClient httpClient; private final ObjectMapper objectMapper; private final String protocolVersion = "2025-11-25"; @@ -53,12 +54,31 @@ public class HttpMcpTransport implements Transport { * @param baseUrl The base URL of the remote service. */ public HttpMcpTransport(String baseUrl) { - this(baseUrl, HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build()); + this(baseUrl, Map.of()); + } + + /** + * Constructs a new HttpMcpTransport with a base URL and client-level headers. + * + * @param baseUrl The base URL of the remote service. + * @param clientHeaders The client-level headers. + */ + public HttpMcpTransport(String baseUrl, Map clientHeaders) { + this(baseUrl, clientHeaders, HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build()); } /** Package-private constructor for unit testing. */ HttpMcpTransport(String baseUrl, HttpClient httpClient) { + this(baseUrl, Map.of(), httpClient); + } + + /** Package-private constructor for unit testing. */ + HttpMcpTransport(String baseUrl, Map clientHeaders, HttpClient httpClient) { this.baseUrl = baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl; + this.clientHeaders = + clientHeaders != null + ? java.util.Collections.unmodifiableMap(new java.util.HashMap<>(clientHeaders)) + : java.util.Collections.emptyMap(); this.httpClient = httpClient; this.objectMapper = new ObjectMapper(); } @@ -68,9 +88,14 @@ public String getBaseUrl() { return this.baseUrl; } - private synchronized CompletableFuture ensureInitialized(String authHeader) { + private synchronized CompletableFuture ensureInitialized(Map headers) { if (initialized) return CompletableFuture.completedFuture(null); try { + Map handshakeHeaders = new HashMap<>(this.clientHeaders); + String authHeader = headers.get("Authorization"); + if (authHeader != null) { + handshakeHeaders.put("Authorization", authHeader); + } if (this.baseUrl.toLowerCase(java.util.Locale.ROOT).startsWith("http://") && authHeader != null) { logger.warning(HTTP_WARNING); @@ -84,7 +109,7 @@ private synchronized CompletableFuture ensureInitialized(String authHeader .uri(URI.create(baseUrl)) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body)); - if (authHeader != null) req.header("Authorization", authHeader); + handshakeHeaders.forEach(req::setHeader); return httpClient .sendAsync(req.build(), HttpResponse.BodyHandlers.ofString()) @@ -104,7 +129,7 @@ private synchronized CompletableFuture ensureInitialized(String authHeader .header("Content-Type", "application/json") .header("MCP-Protocol-Version", protocolVersion) .POST(HttpRequest.BodyPublishers.ofString(notifBody)); - if (authHeader != null) nReq.header("Authorization", authHeader); + handshakeHeaders.forEach(nReq::setHeader); return httpClient .sendAsync(nReq.build(), HttpResponse.BodyHandlers.ofString()) @@ -124,12 +149,11 @@ private synchronized CompletableFuture ensureInitialized(String authHeader @Override public CompletableFuture listTools( String toolsetName, Map headers) { - String authHeader = headers.get("Authorization"); if (this.baseUrl.toLowerCase(java.util.Locale.ROOT).startsWith("http://") && !headers.isEmpty()) { logger.warning(HTTP_WARNING); } - return ensureInitialized(authHeader) + return ensureInitialized(headers) .thenCompose( v -> { String path = toolsetName != null && !toolsetName.isEmpty() ? "/" + toolsetName : ""; @@ -157,12 +181,11 @@ public CompletableFuture listTools( @Override public CompletableFuture invokeTool( String toolName, Map arguments, Map headers) { - String authHeader = headers.get("Authorization"); if (this.baseUrl.toLowerCase(java.util.Locale.ROOT).startsWith("http://") && !headers.isEmpty()) { logger.warning(HTTP_WARNING); } - return ensureInitialized(authHeader) + return ensureInitialized(headers) .thenCompose( v -> { try { diff --git a/src/main/java/com/google/cloud/mcp/McpToolboxClientBuilder.java b/src/main/java/com/google/cloud/mcp/McpToolboxClientBuilder.java index 24e4a68..a05e149 100644 --- a/src/main/java/com/google/cloud/mcp/McpToolboxClientBuilder.java +++ b/src/main/java/com/google/cloud/mcp/McpToolboxClientBuilder.java @@ -82,7 +82,7 @@ public McpToolboxClient build() { resolvedProvider = () -> CompletableFuture.completedFuture(bearerKey); } - Transport transport = new HttpMcpTransport(baseUrl); + Transport transport = new HttpMcpTransport(baseUrl, this.headers); return new McpToolboxClientImpl(transport, this.headers, resolvedProvider); } } diff --git a/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java b/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java index 7dbc486..65c18e5 100644 --- a/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java +++ b/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java @@ -55,25 +55,6 @@ public McpToolboxClientImpl( this.objectMapper = new ObjectMapper(); } - /** - * Constructs a new McpToolboxClientImpl with generic headers. - * - * @param baseUrl The base URL of the MCP Toolbox Server. - * @param headers The HTTP headers to include in requests. - */ - public McpToolboxClientImpl(String baseUrl, Map headers) { - this(baseUrl, headers, null); - } - - /** - * Constructs a new McpToolboxClientImpl with a credentials provider. - * - * @param baseUrl The base URL of the MCP Toolbox Server. - * @param credentialsProvider The provider for authentication headers. - */ - public McpToolboxClientImpl(String baseUrl, CredentialsProvider credentialsProvider) { - this(baseUrl, Collections.emptyMap(), credentialsProvider); - } /** * Deprecated constructor. Use the constructor accepting {@link CredentialsProvider} instead. @@ -82,6 +63,7 @@ public McpToolboxClientImpl(String baseUrl, CredentialsProvider credentialsProvi * @param apiKey The static API key. */ @Deprecated + public McpToolboxClientImpl(String baseUrl, String apiKey) { this(new HttpMcpTransport(baseUrl), Collections.emptyMap(), apiKeyToProvider(apiKey)); } diff --git a/src/test/java/com/google/cloud/mcp/McpToolboxClientBuilderTest.java b/src/test/java/com/google/cloud/mcp/McpToolboxClientBuilderTest.java index bd7a133..8bccbc2 100644 --- a/src/test/java/com/google/cloud/mcp/McpToolboxClientBuilderTest.java +++ b/src/test/java/com/google/cloud/mcp/McpToolboxClientBuilderTest.java @@ -56,10 +56,10 @@ void testBaseUrlValidation() { void testBaseUrlTrailingSlashNormalization() throws Exception { McpToolboxClient client = McpToolboxClient.builder().baseUrl("http://localhost:8080/").build(); - Field baseUrlField = McpToolboxClientImpl.class.getDeclaredField("baseUrl"); - baseUrlField.setAccessible(true); - String baseUrl = (String) baseUrlField.get(client); - assertEquals("http://localhost:8080", baseUrl); + Field transportField = McpToolboxClientImpl.class.getDeclaredField("transport"); + transportField.setAccessible(true); + Transport transport = (Transport) transportField.get(client); + assertEquals("http://localhost:8080", transport.getBaseUrl()); } @Test diff --git a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplErrorsTest.java b/src/test/java/com/google/cloud/mcp/McpToolboxClientImplErrorsTest.java index 3d38694..e038111 100644 --- a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplErrorsTest.java +++ b/src/test/java/com/google/cloud/mcp/McpToolboxClientImplErrorsTest.java @@ -44,13 +44,10 @@ class McpToolboxClientImplErrorsTest { @BeforeEach @SuppressWarnings("unchecked") void setUp() throws Exception { - client = new McpToolboxClientImpl("http://localhost:8080", "test-api-key"); mockHttpClient = mock(HttpClient.class); - - // Inject mock HttpClient using reflection - Field httpClientField = McpToolboxClientImpl.class.getDeclaredField("httpClient"); - httpClientField.setAccessible(true); - httpClientField.set(client, mockHttpClient); + HttpMcpTransport transport = new HttpMcpTransport("http://localhost:8080", mockHttpClient); + CredentialsProvider provider = () -> CompletableFuture.completedFuture("Bearer test-api-key"); + client = new McpToolboxClientImpl(transport, java.util.Collections.emptyMap(), provider); } @Test diff --git a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplHeadersTest.java b/src/test/java/com/google/cloud/mcp/McpToolboxClientImplHeadersTest.java index fc99df8..72b7031 100644 --- a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplHeadersTest.java +++ b/src/test/java/com/google/cloud/mcp/McpToolboxClientImplHeadersTest.java @@ -47,13 +47,10 @@ class McpToolboxClientImplHeadersTest { @BeforeEach @SuppressWarnings("unchecked") void setUp() throws Exception { - client = new McpToolboxClientImpl("http://localhost:8080", "test-api-key"); mockHttpClient = mock(HttpClient.class); - - // Inject mock HttpClient using reflection - Field httpClientField = McpToolboxClientImpl.class.getDeclaredField("httpClient"); - httpClientField.setAccessible(true); - httpClientField.set(client, mockHttpClient); + HttpMcpTransport transport = new HttpMcpTransport("http://localhost:8080", mockHttpClient); + CredentialsProvider provider = () -> CompletableFuture.completedFuture("Bearer test-api-key"); + client = new McpToolboxClientImpl(transport, java.util.Collections.emptyMap(), provider); } @Test @@ -66,9 +63,12 @@ void testCustomHeadersPopulatedInAllRequests() throws Exception { .build(); HttpClient mockHttpClient = mock(HttpClient.class); - Field httpClientField = McpToolboxClientImpl.class.getDeclaredField("httpClient"); + Field transportField = McpToolboxClientImpl.class.getDeclaredField("transport"); + transportField.setAccessible(true); + HttpMcpTransport transport = (HttpMcpTransport) transportField.get(client); + Field httpClientField = HttpMcpTransport.class.getDeclaredField("httpClient"); httpClientField.setAccessible(true); - httpClientField.set(client, mockHttpClient); + httpClientField.set(transport, mockHttpClient); HttpResponse initResponse = mock(HttpResponse.class); when(initResponse.statusCode()).thenReturn(200); @@ -147,9 +147,12 @@ void testExtraHeadersOverrideAndAuthPriority() throws Exception { .build(); HttpClient mockHttpClient = mock(HttpClient.class); - Field httpClientField = McpToolboxClientImpl.class.getDeclaredField("httpClient"); + Field transportField = McpToolboxClientImpl.class.getDeclaredField("transport"); + transportField.setAccessible(true); + HttpMcpTransport transport = (HttpMcpTransport) transportField.get(client); + Field httpClientField = HttpMcpTransport.class.getDeclaredField("httpClient"); httpClientField.setAccessible(true); - httpClientField.set(client, mockHttpClient); + httpClientField.set(transport, mockHttpClient); HttpResponse initResponse = mock(HttpResponse.class); when(initResponse.statusCode()).thenReturn(200); @@ -217,9 +220,12 @@ void testNoDuplicateHeaders() throws Exception { .build(); HttpClient mockHttpClient = mock(HttpClient.class); - Field httpClientField = McpToolboxClientImpl.class.getDeclaredField("httpClient"); + Field transportField = McpToolboxClientImpl.class.getDeclaredField("transport"); + transportField.setAccessible(true); + HttpMcpTransport transport = (HttpMcpTransport) transportField.get(client); + Field httpClientField = HttpMcpTransport.class.getDeclaredField("httpClient"); httpClientField.setAccessible(true); - httpClientField.set(client, mockHttpClient); + httpClientField.set(transport, 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/McpToolboxClientImplJsonRpcTest.java index ce9f29f..2a828d1 100644 --- a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplJsonRpcTest.java +++ b/src/test/java/com/google/cloud/mcp/McpToolboxClientImplJsonRpcTest.java @@ -48,13 +48,10 @@ class McpToolboxClientImplJsonRpcTest { @BeforeEach @SuppressWarnings("unchecked") void setUp() throws Exception { - client = new McpToolboxClientImpl("http://localhost:8080", "test-api-key"); mockHttpClient = mock(HttpClient.class); - - // Inject mock HttpClient using reflection - Field httpClientField = McpToolboxClientImpl.class.getDeclaredField("httpClient"); - httpClientField.setAccessible(true); - httpClientField.set(client, mockHttpClient); + HttpMcpTransport transport = new HttpMcpTransport("http://localhost:8080", mockHttpClient); + CredentialsProvider provider = () -> CompletableFuture.completedFuture("Bearer test-api-key"); + client = new McpToolboxClientImpl(transport, java.util.Collections.emptyMap(), provider); } @Test diff --git a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplTest.java b/src/test/java/com/google/cloud/mcp/McpToolboxClientImplTest.java index f23ad8a..a15cbdf 100644 --- a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplTest.java +++ b/src/test/java/com/google/cloud/mcp/McpToolboxClientImplTest.java @@ -55,13 +55,10 @@ class McpToolboxClientImplTest { @BeforeEach @SuppressWarnings("unchecked") void setUp() throws Exception { - client = new McpToolboxClientImpl("http://localhost:8080", "test-api-key"); mockHttpClient = mock(HttpClient.class); - - // Inject mock HttpClient using reflection - Field httpClientField = McpToolboxClientImpl.class.getDeclaredField("httpClient"); - httpClientField.setAccessible(true); - httpClientField.set(client, mockHttpClient); + HttpMcpTransport transport = new HttpMcpTransport("http://localhost:8080", mockHttpClient); + CredentialsProvider provider = () -> CompletableFuture.completedFuture("Bearer test-api-key"); + client = new McpToolboxClientImpl(transport, java.util.Collections.emptyMap(), provider); } @Test @@ -412,11 +409,10 @@ void testLoadToolset_successWithAuthBinds() throws Exception { @Test void testEnsureInitialized_withHttpsBaseUrl() throws Exception { + HttpMcpTransport transport = new HttpMcpTransport("https://localhost:8443", mockHttpClient); + CredentialsProvider provider = () -> CompletableFuture.completedFuture("Bearer test-api-key"); McpToolboxClientImpl httpsClient = - new McpToolboxClientImpl("https://localhost:8443", "test-api-key"); - Field httpClientField = McpToolboxClientImpl.class.getDeclaredField("httpClient"); - httpClientField.setAccessible(true); - httpClientField.set(httpsClient, mockHttpClient); + new McpToolboxClientImpl(transport, java.util.Collections.emptyMap(), provider); HttpResponse initResponse = mock(HttpResponse.class); when(initResponse.statusCode()).thenReturn(200); @@ -441,11 +437,9 @@ void testEnsureInitialized_withHttpsBaseUrl() throws Exception { @Test void testEnsureInitialized_withoutApiKeyFallbackToAdcException() throws Exception { + HttpMcpTransport transport = new HttpMcpTransport("http://localhost:8080", mockHttpClient); McpToolboxClientImpl noAuthClient = - new McpToolboxClientImpl("http://localhost:8080", (String) null); - Field httpClientField = McpToolboxClientImpl.class.getDeclaredField("httpClient"); - httpClientField.setAccessible(true); - httpClientField.set(noAuthClient, mockHttpClient); + new McpToolboxClientImpl(transport, java.util.Collections.emptyMap(), null); HttpResponse initResponse = mock(HttpResponse.class); when(initResponse.statusCode()).thenReturn(200); @@ -506,15 +500,9 @@ void testLoadToolset_withNullToolsetName() throws Exception { @Test void testLoadToolset_withInvalidUriThrowsException() { - McpToolboxClientImpl badClient = new McpToolboxClientImpl("http://invalid uri", (String) null); - Field httpClientField = null; - try { - httpClientField = McpToolboxClientImpl.class.getDeclaredField("httpClient"); - httpClientField.setAccessible(true); - httpClientField.set(badClient, mockHttpClient); - } catch (Exception e) { - org.junit.jupiter.api.Assertions.fail(e); - } + HttpMcpTransport transport = new HttpMcpTransport("http://invalid uri", mockHttpClient); + McpToolboxClientImpl badClient = + new McpToolboxClientImpl(transport, java.util.Collections.emptyMap(), null); Exception exception = org.junit.jupiter.api.Assertions.assertThrows( @@ -525,14 +513,12 @@ void testLoadToolset_withInvalidUriThrowsException() { @Test void testInvokeTool_withInvalidUriThrowsException() throws Exception { - McpToolboxClientImpl badClient = new McpToolboxClientImpl("http://invalid uri", (String) null); - Field httpClientField = McpToolboxClientImpl.class.getDeclaredField("httpClient"); - httpClientField.setAccessible(true); - httpClientField.set(badClient, mockHttpClient); - - Field initField = McpToolboxClientImpl.class.getDeclaredField("initialized"); + HttpMcpTransport transport = new HttpMcpTransport("http://invalid uri", mockHttpClient); + Field initField = HttpMcpTransport.class.getDeclaredField("initialized"); initField.setAccessible(true); - initField.set(badClient, true); // bypass initialization + initField.set(transport, true); // bypass initialization + McpToolboxClientImpl badClient = + new McpToolboxClientImpl(transport, java.util.Collections.emptyMap(), null); Exception exception = org.junit.jupiter.api.Assertions.assertThrows( @@ -591,11 +577,9 @@ void testGetAuthorizationHeader_withAdcException() throws Exception { @Test void testEnsureInitialized_withNullAuthHeader() throws Exception { + HttpMcpTransport transport = new HttpMcpTransport("http://localhost:8080", mockHttpClient); McpToolboxClientImpl noAuthClient = - new McpToolboxClientImpl("http://localhost:8080", (String) null); - Field httpClientField = McpToolboxClientImpl.class.getDeclaredField("httpClient"); - httpClientField.setAccessible(true); - httpClientField.set(noAuthClient, mockHttpClient); + new McpToolboxClientImpl(transport, java.util.Collections.emptyMap(), null); HttpResponse initResponse = mock(HttpResponse.class); when(initResponse.statusCode()).thenReturn(200); @@ -609,12 +593,13 @@ void testEnsureInitialized_withNullAuthHeader() throws Exception { .thenReturn(CompletableFuture.completedFuture(initResponse)) .thenReturn(CompletableFuture.completedFuture(notifResponse)); + Method initMethod = - McpToolboxClientImpl.class.getDeclaredMethod("ensureInitialized", String.class); + HttpMcpTransport.class.getDeclaredMethod("ensureInitialized", Map.class); initMethod.setAccessible(true); CompletableFuture future = - (CompletableFuture) initMethod.invoke(noAuthClient, (String) null); + (CompletableFuture) initMethod.invoke(transport, java.util.Collections.emptyMap()); future.join(); // should complete and NOT set Authorization header ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(HttpRequest.class); @@ -629,9 +614,10 @@ void testConstructor_withTrailingSlashAndNullHeaders() throws Exception { McpToolboxClientImpl clientWithSlash = new McpToolboxClientImpl("http://localhost:8080/", (Map) null); - Field baseUrlField = McpToolboxClientImpl.class.getDeclaredField("baseUrl"); - baseUrlField.setAccessible(true); - assertEquals("http://localhost:8080", baseUrlField.get(clientWithSlash)); + Field transportField = McpToolboxClientImpl.class.getDeclaredField("transport"); + transportField.setAccessible(true); + Transport transport = (Transport) transportField.get(clientWithSlash); + assertEquals("http://localhost:8080", transport.getBaseUrl()); Field headersField = McpToolboxClientImpl.class.getDeclaredField("headers"); headersField.setAccessible(true); @@ -644,11 +630,9 @@ void testConstructor_withTrailingSlashAndNullHeaders() throws Exception { void testEnsureInitialized_withCustomHeaders() throws Exception { Map customHeaders = Map.of("X-Custom-Header", "custom-val", "Authorization", "some-apiKey"); + HttpMcpTransport transport = new HttpMcpTransport("http://localhost:8080", customHeaders, mockHttpClient); McpToolboxClientImpl customClient = - new McpToolboxClientImpl("http://localhost:8080", customHeaders); - Field httpClientField = McpToolboxClientImpl.class.getDeclaredField("httpClient"); - httpClientField.setAccessible(true); - httpClientField.set(customClient, mockHttpClient); + new McpToolboxClientImpl(transport, customHeaders, null); HttpResponse initResponse = mock(HttpResponse.class); when(initResponse.statusCode()).thenReturn(200); @@ -731,10 +715,9 @@ void testConstructor_withCredentialsProvider() throws Exception { @Test @SuppressWarnings("unchecked") void testDefaultLoadToolset() throws Exception { - McpToolboxClientImpl client = new McpToolboxClientImpl("http://localhost:8080", (String) null); - Field httpClientField = McpToolboxClientImpl.class.getDeclaredField("httpClient"); - httpClientField.setAccessible(true); - httpClientField.set(client, mockHttpClient); + HttpMcpTransport transport = new HttpMcpTransport("http://localhost:8080", mockHttpClient); + McpToolboxClientImpl client = + new McpToolboxClientImpl(transport, java.util.Collections.emptyMap(), null); HttpResponse initResponse = mock(HttpResponse.class); when(initResponse.statusCode()).thenReturn(200); From 2e30e5b0b9d74a8898db21be125b221ea12f6128 Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Mon, 22 Jun 2026 14:26:09 +0530 Subject: [PATCH 06/20] style: format files using google-java-format --- .../java/com/google/cloud/mcp/HttpMcpTransport.java | 5 ++++- .../com/google/cloud/mcp/McpToolboxClientImpl.java | 1 - .../cloud/mcp/McpToolboxClientImplErrorsTest.java | 1 - .../cloud/mcp/McpToolboxClientImplJsonRpcTest.java | 1 - .../com/google/cloud/mcp/McpToolboxClientImplTest.java | 10 ++++------ 5 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/google/cloud/mcp/HttpMcpTransport.java b/src/main/java/com/google/cloud/mcp/HttpMcpTransport.java index f884870..b19e6e9 100644 --- a/src/main/java/com/google/cloud/mcp/HttpMcpTransport.java +++ b/src/main/java/com/google/cloud/mcp/HttpMcpTransport.java @@ -64,7 +64,10 @@ public HttpMcpTransport(String baseUrl) { * @param clientHeaders The client-level headers. */ public HttpMcpTransport(String baseUrl, Map clientHeaders) { - this(baseUrl, clientHeaders, HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build()); + this( + baseUrl, + clientHeaders, + HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(10)).build()); } /** Package-private constructor for unit testing. */ diff --git a/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java b/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java index 65c18e5..0206447 100644 --- a/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java +++ b/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java @@ -55,7 +55,6 @@ public McpToolboxClientImpl( this.objectMapper = new ObjectMapper(); } - /** * Deprecated constructor. Use the constructor accepting {@link CredentialsProvider} instead. * diff --git a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplErrorsTest.java b/src/test/java/com/google/cloud/mcp/McpToolboxClientImplErrorsTest.java index e038111..36d1686 100644 --- a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplErrorsTest.java +++ b/src/test/java/com/google/cloud/mcp/McpToolboxClientImplErrorsTest.java @@ -24,7 +24,6 @@ import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.ObjectMapper; -import java.lang.reflect.Field; 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/McpToolboxClientImplJsonRpcTest.java b/src/test/java/com/google/cloud/mcp/McpToolboxClientImplJsonRpcTest.java index 2a828d1..3a9a99e 100644 --- a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplJsonRpcTest.java +++ b/src/test/java/com/google/cloud/mcp/McpToolboxClientImplJsonRpcTest.java @@ -26,7 +26,6 @@ import static org.mockito.Mockito.when; import com.fasterxml.jackson.databind.ObjectMapper; -import java.lang.reflect.Field; 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/McpToolboxClientImplTest.java index a15cbdf..5ad503e 100644 --- a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplTest.java +++ b/src/test/java/com/google/cloud/mcp/McpToolboxClientImplTest.java @@ -593,9 +593,7 @@ void testEnsureInitialized_withNullAuthHeader() throws Exception { .thenReturn(CompletableFuture.completedFuture(initResponse)) .thenReturn(CompletableFuture.completedFuture(notifResponse)); - - Method initMethod = - HttpMcpTransport.class.getDeclaredMethod("ensureInitialized", Map.class); + Method initMethod = HttpMcpTransport.class.getDeclaredMethod("ensureInitialized", Map.class); initMethod.setAccessible(true); CompletableFuture future = @@ -630,9 +628,9 @@ void testConstructor_withTrailingSlashAndNullHeaders() throws Exception { void testEnsureInitialized_withCustomHeaders() throws Exception { Map customHeaders = Map.of("X-Custom-Header", "custom-val", "Authorization", "some-apiKey"); - HttpMcpTransport transport = new HttpMcpTransport("http://localhost:8080", customHeaders, mockHttpClient); - McpToolboxClientImpl customClient = - new McpToolboxClientImpl(transport, customHeaders, null); + HttpMcpTransport transport = + new HttpMcpTransport("http://localhost:8080", customHeaders, mockHttpClient); + McpToolboxClientImpl customClient = new McpToolboxClientImpl(transport, customHeaders, null); HttpResponse initResponse = mock(HttpResponse.class); when(initResponse.statusCode()).thenReturn(200); From 857aefd93f8b0928e71dcd708ef24d038888d6bd Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Mon, 22 Jun 2026 14:30:28 +0530 Subject: [PATCH 07/20] test: add coverage boosters for transport and client to achieve 100% line coverage --- .../cloud/mcp/McpToolboxClientImplTest.java | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) diff --git a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplTest.java b/src/test/java/com/google/cloud/mcp/McpToolboxClientImplTest.java index 5ad503e..57b2053 100644 --- a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplTest.java +++ b/src/test/java/com/google/cloud/mcp/McpToolboxClientImplTest.java @@ -739,4 +739,90 @@ void testDefaultLoadToolset() throws Exception { assertNotNull(tools); assertTrue(tools.isEmpty()); } + + @Test + @SuppressWarnings("deprecation") + void testCoverageBoosters() throws Exception { + // 1. Cover HttpMcpTransport close() method + HttpMcpTransport transport = new HttpMcpTransport("http://localhost:8080", mockHttpClient); + transport.close(); + + // 2. Cover HttpMcpTransport constructor with null headers + HttpMcpTransport transportWithNullHeaders = + new HttpMcpTransport("http://localhost:8080", null, mockHttpClient); + assertNotNull(transportWithNullHeaders); + + // 3. Cover McpToolboxClientImpl deprecated constructor 1 + McpToolboxClientImpl client1 = + new McpToolboxClientImpl("http://localhost:8080", java.util.Collections.emptyMap(), null); + assertNotNull(client1); + + // 4. Cover McpToolboxClientImpl deprecated constructor 2 + McpToolboxClientImpl client2 = new McpToolboxClientImpl(transport, null); + assertNotNull(client2); + } + + @Test + void testInvokeTool_withNullHeadersThrows() { + HttpMcpTransport transport = new HttpMcpTransport("http://localhost:8080", mockHttpClient); + McpToolboxClientImpl client = + new McpToolboxClientImpl(transport, java.util.Collections.emptyMap(), null); + + CompletableFuture future = + client.invokeTool("test-tool", java.util.Collections.emptyMap(), null); + java.util.concurrent.ExecutionException ex = + org.junit.jupiter.api.Assertions.assertThrows( + java.util.concurrent.ExecutionException.class, future::get); + assertTrue(ex.getCause() instanceof NullPointerException); + } + + @Test + void testListTools_withInvalidToolsetNameThrows() throws Exception { + HttpMcpTransport transport = new HttpMcpTransport("http://localhost:8080", mockHttpClient); + + // Force transport to be initialized first + Field initField = HttpMcpTransport.class.getDeclaredField("initialized"); + initField.setAccessible(true); + initField.set(transport, true); + + CompletableFuture future = + transport.listTools("invalid path with spaces \\", java.util.Collections.emptyMap()); + java.util.concurrent.ExecutionException ex = + org.junit.jupiter.api.Assertions.assertThrows( + java.util.concurrent.ExecutionException.class, future::get); + assertTrue(ex.getCause() instanceof IllegalArgumentException); + } + + @Test + void testEnsureInitialized_withNotificationSerializationFailure() throws Exception { + HttpMcpTransport transport = new HttpMcpTransport("http://localhost:8080", mockHttpClient); + + // Mock ObjectMapper to throw on notification + ObjectMapper mockMapper = mock(ObjectMapper.class); + when(mockMapper.writeValueAsString(any(JsonRpc.Request.class))).thenReturn("{}"); + when(mockMapper.writeValueAsString(any(JsonRpc.Notification.class))) + .thenThrow(new RuntimeException("Simulated notification serialization failure")); + + Field mapperField = HttpMcpTransport.class.getDeclaredField("objectMapper"); + mapperField.setAccessible(true); + mapperField.set(transport, mockMapper); + + HttpResponse initResponse = mock(HttpResponse.class); + when(initResponse.statusCode()).thenReturn(200); + when(initResponse.body()).thenReturn("{}"); + + when(mockHttpClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(initResponse)); + + Method initMethod = HttpMcpTransport.class.getDeclaredMethod("ensureInitialized", Map.class); + initMethod.setAccessible(true); + + CompletableFuture future = + (CompletableFuture) initMethod.invoke(transport, java.util.Collections.emptyMap()); + + java.util.concurrent.ExecutionException ex = + org.junit.jupiter.api.Assertions.assertThrows( + java.util.concurrent.ExecutionException.class, future::get); + assertTrue(ex.getCause().getMessage().contains("Simulated notification serialization failure")); + } } From dd8d3d49d84a024508132e8cddc31bf801fbb5ed Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Thu, 9 Apr 2026 17:44:04 +0530 Subject: [PATCH 08/20] feat: add default parameter support --- src/main/java/com/google/cloud/mcp/Tool.java | 5 ++ .../com/google/cloud/mcp/ToolDefinition.java | 13 ++- .../java/com/google/cloud/mcp/ToolTest.java | 83 +++++++++++++++++++ 3 files changed, 99 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/google/cloud/mcp/Tool.java b/src/main/java/com/google/cloud/mcp/Tool.java index a69374b..e2409e3 100644 --- a/src/main/java/com/google/cloud/mcp/Tool.java +++ b/src/main/java/com/google/cloud/mcp/Tool.java @@ -149,6 +149,11 @@ private void validateAndSanitizeArgs(Map args) { for (ToolDefinition.Parameter param : definition.parameters()) { Object value = args.get(param.name()); + if (value == null && param.defaultValue() != null) { + value = param.defaultValue(); + args.put(param.name(), value); + } + // A. Check Required Parameters if (param.required() && value == null) { throw new IllegalArgumentException( diff --git a/src/main/java/com/google/cloud/mcp/ToolDefinition.java b/src/main/java/com/google/cloud/mcp/ToolDefinition.java index 586bc1d..0c352a1 100644 --- a/src/main/java/com/google/cloud/mcp/ToolDefinition.java +++ b/src/main/java/com/google/cloud/mcp/ToolDefinition.java @@ -17,6 +17,7 @@ package com.google.cloud.mcp; import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; import java.util.List; /** @@ -36,6 +37,7 @@ public record ToolDefinition( * @param required Whether the parameter is required. * @param description A description of the parameter. * @param authSources A list of authentication sources for this parameter. + * @param defaultValue The default value for the parameter. */ @JsonIgnoreProperties(ignoreUnknown = true) public record Parameter( @@ -43,6 +45,13 @@ public record Parameter( String type, boolean required, String description, - List authSources // Maps services to parameters - ) {} + List authSources, // Maps services to parameters + @JsonProperty("default") Object defaultValue) { + + /** Secondary constructor for backward compatibility. */ + public Parameter( + String name, String type, boolean required, String description, List authSources) { + this(name, type, required, description, authSources, null); + } + } } diff --git a/src/test/java/com/google/cloud/mcp/ToolTest.java b/src/test/java/com/google/cloud/mcp/ToolTest.java index a7976eb..15afadf 100644 --- a/src/test/java/com/google/cloud/mcp/ToolTest.java +++ b/src/test/java/com/google/cloud/mcp/ToolTest.java @@ -19,12 +19,16 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import java.util.ArrayList; +import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -36,6 +40,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; class ToolTest { @@ -404,4 +409,82 @@ void testValidateAndSanitizeArgs_withNullParameters() throws Exception { Tool tool = new Tool("test-tool", def, client); tool.execute(Map.of("any-param", "any-value")).join(); // should bypass validation loop safely } + + @Test + void testDefaultValueInjection() throws Exception { + McpToolboxClient mockClient = mock(McpToolboxClient.class); + + ToolDefinition.Parameter paramWithDefault = + new ToolDefinition.Parameter( + "param1", "string", false, "A parameter", null, "default_value"); + ToolDefinition.Parameter paramNoDefault = + new ToolDefinition.Parameter("param2", "string", false, "Another parameter", null, null); + + ToolDefinition def = + new ToolDefinition("A test tool", List.of(paramWithDefault, paramNoDefault), null); + + Tool tool = new Tool("testTool", def, mockClient); + + when(mockClient.invokeTool(eq("testTool"), any(), any())) + .thenReturn( + CompletableFuture.completedFuture(new ToolResult(Collections.emptyList(), false))); + + Map args = new HashMap<>(); + args.put("param2", "provided_value"); + + CompletableFuture future = tool.execute(args); + future.join(); // Wait for execution + + @SuppressWarnings("unchecked") + ArgumentCaptor> argsCaptor = ArgumentCaptor.forClass(Map.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> headersCaptor = ArgumentCaptor.forClass(Map.class); + + verify(mockClient).invokeTool(eq("testTool"), argsCaptor.capture(), headersCaptor.capture()); + + Map capturedArgs = argsCaptor.getValue(); + + assertEquals( + "default_value", + capturedArgs.get("param1"), + "Default value should be injected when not provided"); + assertEquals("provided_value", capturedArgs.get("param2"), "Provided value should be kept"); + } + + @Test + void testDefaultValueNotOverwritten() throws Exception { + McpToolboxClient mockClient = mock(McpToolboxClient.class); + + ToolDefinition.Parameter paramWithDefault = + new ToolDefinition.Parameter( + "param1", "string", false, "A parameter", null, "default_value"); + + ToolDefinition def = new ToolDefinition("A test tool", List.of(paramWithDefault), null); + + Tool tool = new Tool("testTool", def, mockClient); + + when(mockClient.invokeTool(eq("testTool"), any(), any())) + .thenReturn( + CompletableFuture.completedFuture(new ToolResult(Collections.emptyList(), false))); + + Map args = new HashMap<>(); + args.put("param1", "custom_value"); + + CompletableFuture future = tool.execute(args); + future.join(); // Wait for execution + + @SuppressWarnings("unchecked") + ArgumentCaptor> argsCaptor = ArgumentCaptor.forClass(Map.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> headersCaptor = ArgumentCaptor.forClass(Map.class); + + verify(mockClient).invokeTool(eq("testTool"), argsCaptor.capture(), headersCaptor.capture()); + + Map capturedArgs = argsCaptor.getValue(); + + assertEquals( + "custom_value", + capturedArgs.get("param1"), + "Provided value should not be overwritten by default value"); + } } From cab468966d8bdebfde335b124cb5c817561ae6ab Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Fri, 19 Jun 2026 19:17:01 +0530 Subject: [PATCH 09/20] refactor: address PR review feedback * Supported readOnlyHint and destructiveHint in ToolDefinition (with backward compatible constructors) * Deep copied complex default parameter values before injection to prevent mutability side effects --- src/main/java/com/google/cloud/mcp/Tool.java | 23 +++++++- .../com/google/cloud/mcp/ToolDefinition.java | 14 ++++- .../java/com/google/cloud/mcp/ToolTest.java | 57 +++++++++++++++++++ 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/google/cloud/mcp/Tool.java b/src/main/java/com/google/cloud/mcp/Tool.java index e2409e3..b4d7891 100644 --- a/src/main/java/com/google/cloud/mcp/Tool.java +++ b/src/main/java/com/google/cloud/mcp/Tool.java @@ -16,7 +16,9 @@ package com.google.cloud.mcp; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Objects; import java.util.concurrent.CompletableFuture; @@ -150,7 +152,7 @@ private void validateAndSanitizeArgs(Map args) { Object value = args.get(param.name()); if (value == null && param.defaultValue() != null) { - value = param.defaultValue(); + value = deepCopy(param.defaultValue()); args.put(param.name(), value); } @@ -173,6 +175,25 @@ private void validateAndSanitizeArgs(Map args) { } } + private Object deepCopy(Object value) { + if (value instanceof Map) { + Map map = (Map) value; + Map copy = new HashMap<>(); + for (Map.Entry entry : map.entrySet()) { + copy.put(deepCopy(entry.getKey()), deepCopy(entry.getValue())); + } + return copy; + } else if (value instanceof List) { + List list = (List) value; + List copy = new ArrayList<>(); + for (Object item : list) { + copy.add(deepCopy(item)); + } + return copy; + } + return value; + } + private boolean isTypeMatch(Object value, String type) { switch (type.toLowerCase()) { case "string": diff --git a/src/main/java/com/google/cloud/mcp/ToolDefinition.java b/src/main/java/com/google/cloud/mcp/ToolDefinition.java index 0c352a1..9277d08 100644 --- a/src/main/java/com/google/cloud/mcp/ToolDefinition.java +++ b/src/main/java/com/google/cloud/mcp/ToolDefinition.java @@ -28,7 +28,17 @@ */ @JsonIgnoreProperties(ignoreUnknown = true) public record ToolDefinition( - String description, List parameters, List authRequired) { + String description, + List parameters, + List authRequired, + Boolean readOnlyHint, + Boolean destructiveHint) { + + /** Backward-compatible constructor. */ + public ToolDefinition(String description, List parameters, List authRequired) { + this(description, parameters, authRequired, null, null); + } + /** * Represents a parameter of a tool. * @@ -48,7 +58,7 @@ public record Parameter( List authSources, // Maps services to parameters @JsonProperty("default") Object defaultValue) { - /** Secondary constructor for backward compatibility. */ + /** Backward-compatible constructor. */ public Parameter( String name, String type, boolean required, String description, List authSources) { this(name, type, required, description, authSources, null); diff --git a/src/test/java/com/google/cloud/mcp/ToolTest.java b/src/test/java/com/google/cloud/mcp/ToolTest.java index 15afadf..dc57197 100644 --- a/src/test/java/com/google/cloud/mcp/ToolTest.java +++ b/src/test/java/com/google/cloud/mcp/ToolTest.java @@ -487,4 +487,61 @@ void testDefaultValueNotOverwritten() throws Exception { capturedArgs.get("param1"), "Provided value should not be overwritten by default value"); } + + @Test + void testDefaultValueDeepCloning() throws Exception { + McpToolboxClient mockClient = mock(McpToolboxClient.class); + + Map complexDefault = new HashMap<>(); + complexDefault.put("key", "value"); + + ToolDefinition.Parameter paramWithDefault = + new ToolDefinition.Parameter( + "param1", "object", false, "A parameter", null, complexDefault); + + ToolDefinition def = new ToolDefinition("A test tool", List.of(paramWithDefault), null); + + Tool tool = new Tool("testTool", def, mockClient); + + when(mockClient.invokeTool(eq("testTool"), any(), any())) + .thenReturn( + CompletableFuture.completedFuture(new ToolResult(Collections.emptyList(), false))); + + Map args = new HashMap<>(); + CompletableFuture future = tool.execute(args); + future.join(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> argsCaptor = ArgumentCaptor.forClass(Map.class); + verify(mockClient).invokeTool(eq("testTool"), argsCaptor.capture(), any()); + + Map capturedArgs = argsCaptor.getValue(); + @SuppressWarnings("unchecked") + Map injectedDefault = (Map) capturedArgs.get("param1"); + + // Mutate the injected map + injectedDefault.put("key", "mutated_value"); + + // Ensure the original defaultValue stored in the definition remains untouched + @SuppressWarnings("unchecked") + Map defValueInDefinition = + (Map) def.parameters().get(0).defaultValue(); + assertEquals( + "value", + defValueInDefinition.get("key"), + "The default value in definition must remain unmutated"); + } + + @Test + void testToolDefinitionHints() { + ToolDefinition defWithHints = + new ToolDefinition("A test tool", List.of(), List.of(), true, false); + + assertEquals(true, defWithHints.readOnlyHint()); + assertEquals(false, defWithHints.destructiveHint()); + + ToolDefinition defWithoutHints = new ToolDefinition("A test tool", List.of(), List.of()); + assertEquals(null, defWithoutHints.readOnlyHint()); + assertEquals(null, defWithoutHints.destructiveHint()); + } } From 22b38d77db2d5f1f85377de7b86d0786288dbe9e Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Mon, 22 Jun 2026 14:38:05 +0530 Subject: [PATCH 10/20] refactor: port parameter default and schema hints parsing to transport layer and add integration tests --- .../google/cloud/mcp/HttpMcpTransport.java | 18 ++++++- .../cloud/mcp/McpToolboxClientImplTest.java | 53 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/google/cloud/mcp/HttpMcpTransport.java b/src/main/java/com/google/cloud/mcp/HttpMcpTransport.java index b19e6e9..caf5216 100644 --- a/src/main/java/com/google/cloud/mcp/HttpMcpTransport.java +++ b/src/main/java/com/google/cloud/mcp/HttpMcpTransport.java @@ -282,17 +282,31 @@ private TransportManifest handleListToolsResponse(HttpResponse response) } } + Object defaultValue = null; + if (propNode.has("default")) { + JsonNode defNode = propNode.get("default"); + defaultValue = objectMapper.treeToValue(defNode, Object.class); + } + params.add( new ToolDefinition.Parameter( paramName, paramType, requiredSet.contains(paramName), paramDesc, - authSources)); + authSources, + defaultValue)); } } - toolsMap.put(name, new ToolDefinition(description, params, authRequired)); + Boolean readOnlyHint = + toolNode.has("readOnlyHint") ? toolNode.get("readOnlyHint").asBoolean() : null; + Boolean destructiveHint = + toolNode.has("destructiveHint") ? toolNode.get("destructiveHint").asBoolean() : null; + + toolsMap.put( + name, + new ToolDefinition(description, params, authRequired, readOnlyHint, destructiveHint)); } } return new TransportManifest(toolsMap); diff --git a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplTest.java b/src/test/java/com/google/cloud/mcp/McpToolboxClientImplTest.java index 57b2053..d396b86 100644 --- a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplTest.java +++ b/src/test/java/com/google/cloud/mcp/McpToolboxClientImplTest.java @@ -498,6 +498,59 @@ void testLoadToolset_withNullToolsetName() throws Exception { assertTrue(tools.isEmpty()); } + @Test + void testLoadToolset_withDefaultValuesAndHints() throws Exception { + HttpResponse initResponse = mock(HttpResponse.class); + when(initResponse.statusCode()).thenReturn(200); + when(initResponse.body()).thenReturn("{}"); + + HttpResponse notifResponse = mock(HttpResponse.class); + when(notifResponse.statusCode()).thenReturn(200); + when(notifResponse.body()).thenReturn("{}"); + + HttpResponse listResponse = mock(HttpResponse.class); + when(listResponse.statusCode()).thenReturn(200); + when(listResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{\"tools\":[{" + + "\"name\":\"test-tool\"," + + "\"description\":\"A test tool description\"," + + "\"readOnlyHint\":true," + + "\"destructiveHint\":false," + + "\"inputSchema\":{" + + " \"type\":\"object\"," + + " \"properties\":{" + + " \"param1\":{" + + " \"type\":\"string\"," + + " \"description\":\"parameter 1\"," + + " \"default\":\"default-val\"" + + " }" + + " }" + + "}" + + "}]}}"); + + when(mockHttpClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(initResponse)) + .thenReturn(CompletableFuture.completedFuture(notifResponse)) + .thenReturn(CompletableFuture.completedFuture(listResponse)); + + Map tools = client.loadToolset(null).join(); + assertNotNull(tools); + assertEquals(1, tools.size()); + + ToolDefinition def = tools.get("test-tool"); + assertNotNull(def); + assertEquals("A test tool description", def.description()); + assertEquals(true, def.readOnlyHint()); + assertEquals(false, def.destructiveHint()); + + assertEquals(1, def.parameters().size()); + ToolDefinition.Parameter param = def.parameters().get(0); + assertEquals("param1", param.name()); + assertEquals("string", param.type()); + assertEquals("default-val", param.defaultValue()); + } + @Test void testLoadToolset_withInvalidUriThrowsException() { HttpMcpTransport transport = new HttpMcpTransport("http://invalid uri", mockHttpClient); From 01236f048485a12e08d32a1f558acdade8219531 Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Tue, 7 Apr 2026 11:19:05 +0530 Subject: [PATCH 11/20] feat: add pre and post processors --- .../google/cloud/mcp/McpToolboxClient.java | 17 +++ .../cloud/mcp/McpToolboxClientBuilder.java | 23 +++- .../cloud/mcp/McpToolboxClientImpl.java | 40 +++++-- src/main/java/com/google/cloud/mcp/Tool.java | 107 ++++++++++++++---- .../google/cloud/mcp/ToolPostProcessor.java | 33 ++++++ .../google/cloud/mcp/ToolPreProcessor.java | 34 ++++++ .../java/com/google/cloud/mcp/ToolTest.java | 107 +++++++++++++++++- 7 files changed, 325 insertions(+), 36 deletions(-) create mode 100644 src/main/java/com/google/cloud/mcp/ToolPostProcessor.java create mode 100644 src/main/java/com/google/cloud/mcp/ToolPreProcessor.java diff --git a/src/main/java/com/google/cloud/mcp/McpToolboxClient.java b/src/main/java/com/google/cloud/mcp/McpToolboxClient.java index bb2fbff..b2d24b9 100644 --- a/src/main/java/com/google/cloud/mcp/McpToolboxClient.java +++ b/src/main/java/com/google/cloud/mcp/McpToolboxClient.java @@ -135,6 +135,7 @@ interface Builder { */ Builder headers(Map headers); + /** /** * Sets the credentials provider for dynamic authorization header resolution. * @@ -143,6 +144,22 @@ interface Builder { */ Builder credentialsProvider(CredentialsProvider credentialsProvider); + /** + * Adds a global pre-processor that will be applied to all tools loaded by this client. + * + * @param preProcessor The pre-processor to add. + * @return The builder instance. + */ + Builder preProcessor(ToolPreProcessor preProcessor); + + /** + * Adds a global post-processor that will be applied to all tools loaded by this client. + * + * @param postProcessor The post-processor to add. + * @return The builder instance. + */ + Builder postProcessor(ToolPostProcessor postProcessor); + /** * Builds and returns a new {@link McpToolboxClient} instance. * diff --git a/src/main/java/com/google/cloud/mcp/McpToolboxClientBuilder.java b/src/main/java/com/google/cloud/mcp/McpToolboxClientBuilder.java index a05e149..cd308dd 100644 --- a/src/main/java/com/google/cloud/mcp/McpToolboxClientBuilder.java +++ b/src/main/java/com/google/cloud/mcp/McpToolboxClientBuilder.java @@ -16,7 +16,9 @@ package com.google.cloud.mcp; +import java.util.ArrayList; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -26,6 +28,8 @@ public class McpToolboxClientBuilder implements McpToolboxClient.Builder { private String apiKey; private Map headers = new HashMap<>(); private CredentialsProvider credentialsProvider; + private final List preProcessors = new ArrayList<>(); + private final List postProcessors = new ArrayList<>(); /** Constructs a new McpToolboxClientBuilder. */ public McpToolboxClientBuilder() {} @@ -56,6 +60,22 @@ public McpToolboxClient.Builder credentialsProvider(CredentialsProvider credenti return this; } + @Override + public McpToolboxClient.Builder preProcessor(ToolPreProcessor preProcessor) { + if (preProcessor != null) { + this.preProcessors.add(preProcessor); + } + return this; + } + + @Override + public McpToolboxClient.Builder postProcessor(ToolPostProcessor postProcessor) { + if (postProcessor != null) { + this.postProcessors.add(postProcessor); + } + return this; + } + @Override public McpToolboxClient build() { if (baseUrl == null || baseUrl.isEmpty()) { @@ -83,6 +103,7 @@ public McpToolboxClient build() { } Transport transport = new HttpMcpTransport(baseUrl, this.headers); - return new McpToolboxClientImpl(transport, this.headers, resolvedProvider); + return new McpToolboxClientImpl( + transport, this.headers, resolvedProvider, preProcessors, postProcessors); } } diff --git a/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java b/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java index 0206447..75a6762 100644 --- a/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java +++ b/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java @@ -37,6 +37,8 @@ public class McpToolboxClientImpl implements McpToolboxClient { private final Map headers; private final CredentialsProvider credentialsProvider; private final ObjectMapper objectMapper; + private final List preProcessors; + private final List postProcessors; /** * Constructs a new McpToolboxClientImpl. @@ -46,13 +48,7 @@ public class McpToolboxClientImpl implements McpToolboxClient { */ public McpToolboxClientImpl( Transport transport, Map headers, CredentialsProvider credentialsProvider) { - this.transport = transport; - this.headers = - headers != null - ? java.util.Collections.unmodifiableMap(new java.util.HashMap<>(headers)) - : java.util.Collections.emptyMap(); - this.credentialsProvider = credentialsProvider; - this.objectMapper = new ObjectMapper(); + this(transport, headers, credentialsProvider, null, null); } /** @@ -110,6 +106,24 @@ private static CredentialsProvider apiKeyToProvider(String apiKey) { return () -> CompletableFuture.completedFuture(bearerKey); } + /** Primary constructor for McpToolboxClientImpl. */ + public McpToolboxClientImpl( + Transport transport, + Map headers, + CredentialsProvider credentialsProvider, + List preProcessors, + List postProcessors) { + this.transport = transport; + this.headers = + headers != null + ? java.util.Collections.unmodifiableMap(new java.util.HashMap<>(headers)) + : java.util.Collections.emptyMap(); + this.credentialsProvider = credentialsProvider; + this.preProcessors = preProcessors != null ? List.copyOf(preProcessors) : List.of(); + this.postProcessors = postProcessors != null ? List.copyOf(postProcessors) : List.of(); + this.objectMapper = new ObjectMapper(); + } + @Override public CompletableFuture> listTools() { return loadToolset(""); @@ -168,6 +182,12 @@ public CompletableFuture> loadToolset( if (authBinds != null && authBinds.containsKey(toolName)) { authBinds.get(toolName).forEach(tool::addAuthTokenGetter); } + for (ToolPreProcessor preProcessor : this.preProcessors) { + tool.addPreProcessor(preProcessor); + } + for (ToolPostProcessor postProcessor : this.postProcessors) { + tool.addPostProcessor(postProcessor); + } tools.put(toolName, tool); } return tools; @@ -197,6 +217,12 @@ public CompletableFuture loadTool( if (authTokenGetters != null) { authTokenGetters.forEach(tool::addAuthTokenGetter); } + for (ToolPreProcessor preProcessor : this.preProcessors) { + tool.addPreProcessor(preProcessor); + } + for (ToolPostProcessor postProcessor : this.postProcessors) { + tool.addPostProcessor(postProcessor); + } return tool; }); } diff --git a/src/main/java/com/google/cloud/mcp/Tool.java b/src/main/java/com/google/cloud/mcp/Tool.java index b4d7891..4e87b29 100644 --- a/src/main/java/com/google/cloud/mcp/Tool.java +++ b/src/main/java/com/google/cloud/mcp/Tool.java @@ -35,6 +35,8 @@ public class Tool { private final Map boundParameters = new HashMap<>(); private final Map authGetters = new HashMap<>(); + private final List preProcessors = new ArrayList<>(); + private final List postProcessors = new ArrayList<>(); /** * Constructs a new Tool. @@ -103,6 +105,28 @@ public Tool addAuthTokenGetter(String serviceName, AuthTokenGetter getter) { return this; } + /** + * Adds a pre-processor to the tool. + * + * @param processor The pre-processor to add. + * @return The tool instance. + */ + public Tool addPreProcessor(ToolPreProcessor processor) { + this.preProcessors.add(processor); + return this; + } + + /** + * Adds a post-processor to the tool. + * + * @param processor The post-processor to add. + * @return The tool instance. + */ + public Tool addPostProcessor(ToolPostProcessor processor) { + this.postProcessors.add(processor); + return this; + } + /** * Executes the tool with the provided arguments, applying any bound parameters and resolving * authentication tokens. @@ -111,34 +135,69 @@ public Tool addAuthTokenGetter(String serviceName, AuthTokenGetter getter) { * @return A CompletableFuture containing the result of the tool execution. */ public CompletableFuture execute(Map args) { - Map finalArgs = new HashMap<>(args); - Map extraHeaders = new HashMap<>(); - - // 1. Apply Bound Parameters - for (Map.Entry entry : boundParameters.entrySet()) { - Object val = entry.getValue(); - if (val instanceof Supplier) { - finalArgs.put(entry.getKey(), ((Supplier) val).get()); - } else { - finalArgs.put(entry.getKey(), val); - } + CompletableFuture> argsFuture = + CompletableFuture.completedFuture(new HashMap<>(args)); + + for (ToolPreProcessor preProcessor : preProcessors) { + argsFuture = argsFuture.thenCompose(currentArgs -> preProcessor.process(name, currentArgs)); } - // 2. Resolve Auth & Execute - return AuthResolver.resolve(authGetters) - .thenCompose( - resolvedAuth -> { - try { - // Apply credential parameter bindings and extra headers - resolvedAuth.applyTo(finalArgs, extraHeaders, definition); - - // 3. Validation & Cleanup - validateAndSanitizeArgs(finalArgs); - return client.invokeTool(name, finalArgs, extraHeaders); - } catch (Exception e) { - return CompletableFuture.failedFuture(e); + CompletableFuture resultFuture = + argsFuture.thenCompose( + processedArgs -> { + Map finalArgs = new HashMap<>(processedArgs); + Map extraHeaders = new HashMap<>(); + + // 1. Apply Bound Parameters + for (Map.Entry entry : boundParameters.entrySet()) { + Object val = entry.getValue(); + if (val instanceof Supplier) { + finalArgs.put(entry.getKey(), ((Supplier) val).get()); + } else { + finalArgs.put(entry.getKey(), val); + } + } + + // 2. Inject default values for missing parameters + if (definition.parameters() != null) { + for (ToolDefinition.Parameter param : definition.parameters()) { + if (param.defaultValue() != null && !finalArgs.containsKey(param.name())) { + finalArgs.put(param.name(), deepCopy(param.defaultValue())); + } + } } + + // 3. Resolve Auth & Execute + return AuthResolver.resolve(authGetters) + .thenCompose( + resolvedAuth -> { + try { + // Apply credential parameter bindings and extra headers + resolvedAuth.applyTo(finalArgs, extraHeaders, definition); + + // Validation & Cleanup + validateAndSanitizeArgs(finalArgs); + return client.invokeTool(name, finalArgs, extraHeaders); + } catch (Exception e) { + return CompletableFuture.failedFuture(e); + } + }); }); + + for (ToolPostProcessor postProcessor : postProcessors) { + resultFuture = + resultFuture + .handle( + (res, err) -> { + if (err != null) { + return CompletableFuture.failedFuture(err); + } + return postProcessor.process(name, res); + }) + .thenCompose(f -> f); + } + + return resultFuture; } /** Validates arguments against the tool definition and removes null values. */ diff --git a/src/main/java/com/google/cloud/mcp/ToolPostProcessor.java b/src/main/java/com/google/cloud/mcp/ToolPostProcessor.java new file mode 100644 index 0000000..09000bb --- /dev/null +++ b/src/main/java/com/google/cloud/mcp/ToolPostProcessor.java @@ -0,0 +1,33 @@ +/* + * 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; + +import java.util.concurrent.CompletableFuture; + +/** A functional interface for post-processing tool results after invocation. */ +@FunctionalInterface +public interface ToolPostProcessor { + + /** + * Processes the result of a tool after it has been invoked. + * + * @param toolName The name of the tool that was invoked. + * @param result The original tool result. + * @return A CompletableFuture containing the processed tool result. + */ + CompletableFuture process(String toolName, ToolResult result); +} diff --git a/src/main/java/com/google/cloud/mcp/ToolPreProcessor.java b/src/main/java/com/google/cloud/mcp/ToolPreProcessor.java new file mode 100644 index 0000000..a280a85 --- /dev/null +++ b/src/main/java/com/google/cloud/mcp/ToolPreProcessor.java @@ -0,0 +1,34 @@ +/* + * 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; + +import java.util.Map; +import java.util.concurrent.CompletableFuture; + +/** A functional interface for pre-processing tool inputs before invocation. */ +@FunctionalInterface +public interface ToolPreProcessor { + + /** + * Processes the input arguments for a tool before it is invoked. + * + * @param toolName The name of the tool being invoked. + * @param arguments The original arguments provided to the tool. + * @return A CompletableFuture containing the processed arguments. + */ + CompletableFuture> process(String toolName, Map arguments); +} diff --git a/src/test/java/com/google/cloud/mcp/ToolTest.java b/src/test/java/com/google/cloud/mcp/ToolTest.java index dc57197..6639dae 100644 --- a/src/test/java/com/google/cloud/mcp/ToolTest.java +++ b/src/test/java/com/google/cloud/mcp/ToolTest.java @@ -18,12 +18,15 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -34,6 +37,7 @@ import java.util.Map; import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionException; +import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadLocalRandom; @@ -45,15 +49,23 @@ class ToolTest { private ExecutorService pool; + private McpToolboxClient mockClient; + private ToolDefinition toolDefinition; + private Tool tool; @BeforeEach void setUp() { pool = Executors.newFixedThreadPool(8); + mockClient = mock(McpToolboxClient.class); + toolDefinition = new ToolDefinition("Test Tool", null, null); + tool = new Tool("test_tool", toolDefinition, mockClient); } @AfterEach void tearDown() { - pool.shutdownNow(); + if (pool != null) { + pool.shutdownNow(); + } } /** @@ -86,10 +98,10 @@ void execute_withManyConcurrentAuthGetters_doesNotDropCredentials() { new ToolResult(List.of(new ToolResult.Content("text", "ok")), false)); }); - Tool tool = new Tool("race-tool", def, client); + Tool raceTool = new Tool("race-tool", def, client); for (int i = 0; i < services; i++) { final String token = "tok-" + i; - tool.addAuthTokenGetter( + raceTool.addAuthTokenGetter( "svc" + i, () -> CompletableFuture.supplyAsync( @@ -104,7 +116,7 @@ void execute_withManyConcurrentAuthGetters_doesNotDropCredentials() { } for (int iter = 0; iter < iterations; iter++) { - tool.execute(new HashMap<>()).join(); + raceTool.execute(new HashMap<>()).join(); } assertEquals(iterations, capturedHeaders.size(), "every invocation should reach the client"); @@ -544,4 +556,91 @@ void testToolDefinitionHints() { assertEquals(null, defWithoutHints.readOnlyHint()); assertEquals(null, defWithoutHints.destructiveHint()); } + + @Test + @SuppressWarnings("unchecked") + void testExecute_withPreAndPostProcessors_modifiesArgsAndResult() throws Exception { + // Arrange + Map initialArgs = new HashMap<>(); + initialArgs.put("arg1", "val1"); + + ToolResult originalResult = + new ToolResult(List.of(new ToolResult.Content("text", "original")), false); + ToolResult modifiedResult = + new ToolResult(List.of(new ToolResult.Content("text", "modified")), false); + + ToolPreProcessor preProcessor1 = + (name, args) -> { + Map newArgs = new HashMap<>(args); + newArgs.put("arg2", "val2"); + return CompletableFuture.completedFuture(newArgs); + }; + + ToolPreProcessor preProcessor2 = + (name, args) -> { + Map newArgs = new HashMap<>(args); + newArgs.put("arg3", "val3"); + return CompletableFuture.completedFuture(newArgs); + }; + + ToolPostProcessor postProcessor = + (name, result) -> { + if (result.content().get(0).text().equals("original")) { + return CompletableFuture.completedFuture(modifiedResult); + } + return CompletableFuture.completedFuture(result); + }; + + tool.addPreProcessor(preProcessor1); + tool.addPreProcessor(preProcessor2); + tool.addPostProcessor(postProcessor); + + when(mockClient.invokeTool(eq("test_tool"), anyMap(), anyMap())) + .thenReturn(CompletableFuture.completedFuture(originalResult)); + + // Act + CompletableFuture futureResult = tool.execute(initialArgs); + ToolResult finalResult = futureResult.get(); + + // Assert + ArgumentCaptor> argsCaptor = ArgumentCaptor.forClass(Map.class); + verify(mockClient, times(1)).invokeTool(eq("test_tool"), argsCaptor.capture(), anyMap()); + + Map capturedArgs = argsCaptor.getValue(); + assertEquals(3, capturedArgs.size()); + assertEquals("val1", capturedArgs.get("arg1")); + assertEquals("val2", capturedArgs.get("arg2")); + assertEquals("val3", capturedArgs.get("arg3")); + + assertSame(modifiedResult, finalResult); + } + + @Test + void testExecute_preProcessorException_failsFutureWithoutInvokingClient() { + // Arrange + Map initialArgs = new HashMap<>(); + + ToolPreProcessor preProcessor = + (name, args) -> CompletableFuture.failedFuture(new RuntimeException("PreProcessor failed")); + + tool.addPreProcessor(preProcessor); + + // Act + CompletableFuture futureResult = tool.execute(initialArgs); + + // Assert + assertTrue(futureResult.isCompletedExceptionally()); + + Exception exception = null; + try { + futureResult.get(); + } catch (InterruptedException | ExecutionException e) { + exception = e; + } + assertTrue(exception.getCause() instanceof RuntimeException); + assertEquals("PreProcessor failed", exception.getCause().getMessage()); + + verify(mockClient, never()).invokeTool(eq("test_tool"), anyMap(), anyMap()); + verify(mockClient, never()).invokeTool(eq("test_tool"), anyMap()); + } } From b050ecef6f83b74500e788b13dbf28072872a2fc Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Tue, 7 Apr 2026 11:55:45 +0530 Subject: [PATCH 12/20] refactor: simplify CompletableFuture chaining for post processors --- src/main/java/com/google/cloud/mcp/Tool.java | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/src/main/java/com/google/cloud/mcp/Tool.java b/src/main/java/com/google/cloud/mcp/Tool.java index 4e87b29..01c185b 100644 --- a/src/main/java/com/google/cloud/mcp/Tool.java +++ b/src/main/java/com/google/cloud/mcp/Tool.java @@ -185,16 +185,7 @@ public CompletableFuture execute(Map args) { }); for (ToolPostProcessor postProcessor : postProcessors) { - resultFuture = - resultFuture - .handle( - (res, err) -> { - if (err != null) { - return CompletableFuture.failedFuture(err); - } - return postProcessor.process(name, res); - }) - .thenCompose(f -> f); + resultFuture = resultFuture.thenCompose(res -> postProcessor.process(name, res)); } return resultFuture; From df7f336a3cad62d687741c4561a4579a7c1f2605 Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Fri, 19 Jun 2026 19:13:14 +0530 Subject: [PATCH 13/20] fix: use synchronized maps to prevent concurrent mutation of finalArgs and extraHeaders during auth token resolution --- .../com/google/cloud/mcp/McpToolboxClient.java | 1 - src/main/java/com/google/cloud/mcp/Tool.java | 17 +++++------------ 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/google/cloud/mcp/McpToolboxClient.java b/src/main/java/com/google/cloud/mcp/McpToolboxClient.java index b2d24b9..1060272 100644 --- a/src/main/java/com/google/cloud/mcp/McpToolboxClient.java +++ b/src/main/java/com/google/cloud/mcp/McpToolboxClient.java @@ -135,7 +135,6 @@ interface Builder { */ Builder headers(Map headers); - /** /** * Sets the credentials provider for dynamic authorization header resolution. * diff --git a/src/main/java/com/google/cloud/mcp/Tool.java b/src/main/java/com/google/cloud/mcp/Tool.java index 01c185b..49cfe59 100644 --- a/src/main/java/com/google/cloud/mcp/Tool.java +++ b/src/main/java/com/google/cloud/mcp/Tool.java @@ -145,8 +145,10 @@ public CompletableFuture execute(Map args) { CompletableFuture resultFuture = argsFuture.thenCompose( processedArgs -> { - Map finalArgs = new HashMap<>(processedArgs); - Map extraHeaders = new HashMap<>(); + Map finalArgs = + java.util.Collections.synchronizedMap(new HashMap<>(processedArgs)); + Map extraHeaders = + java.util.Collections.synchronizedMap(new HashMap<>()); // 1. Apply Bound Parameters for (Map.Entry entry : boundParameters.entrySet()) { @@ -158,16 +160,7 @@ public CompletableFuture execute(Map args) { } } - // 2. Inject default values for missing parameters - if (definition.parameters() != null) { - for (ToolDefinition.Parameter param : definition.parameters()) { - if (param.defaultValue() != null && !finalArgs.containsKey(param.name())) { - finalArgs.put(param.name(), deepCopy(param.defaultValue())); - } - } - } - - // 3. Resolve Auth & Execute + // 2. Resolve Auth & Execute return AuthResolver.resolve(authGetters) .thenCompose( resolvedAuth -> { From a2a3a5694ef43befb1bb11d82f312e7dbf69b52e Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Mon, 22 Jun 2026 14:43:00 +0530 Subject: [PATCH 14/20] refactor: resolve pre/post-processors merge conflicts, add import and integrate with transport client, and add propagation unit tests --- .../cloud/mcp/McpToolboxClientImpl.java | 1 + .../cloud/mcp/McpToolboxClientImplTest.java | 57 +++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java b/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java index 75a6762..35032eb 100644 --- a/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java +++ b/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java @@ -21,6 +21,7 @@ import java.util.Collections; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; diff --git a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplTest.java b/src/test/java/com/google/cloud/mcp/McpToolboxClientImplTest.java index d396b86..5072eb3 100644 --- a/src/test/java/com/google/cloud/mcp/McpToolboxClientImplTest.java +++ b/src/test/java/com/google/cloud/mcp/McpToolboxClientImplTest.java @@ -878,4 +878,61 @@ void testEnsureInitialized_withNotificationSerializationFailure() throws Excepti java.util.concurrent.ExecutionException.class, future::get); assertTrue(ex.getCause().getMessage().contains("Simulated notification serialization failure")); } + + @Test + @SuppressWarnings("unchecked") + void testClientPrePostProcessorsPropagation() throws Exception { + Transport mockTransport = mock(Transport.class); + ToolDefinition def = + new ToolDefinition("desc", java.util.List.of(), java.util.List.of(), null, null); + TransportManifest manifest = new TransportManifest(java.util.Map.of("test-tool", def)); + + when(mockTransport.listTools(any(), any())) + .thenReturn(CompletableFuture.completedFuture(manifest)); + when(mockTransport.getBaseUrl()).thenReturn("http://localhost:8080"); + + ToolPreProcessor mockPre = mock(ToolPreProcessor.class); + ToolPostProcessor mockPost = mock(ToolPostProcessor.class); + + McpToolboxClientImpl customClient = + new McpToolboxClientImpl( + mockTransport, + java.util.Collections.emptyMap(), + null, + java.util.List.of(mockPre), + java.util.List.of(mockPost)); + + // 1. Verify loadToolset propagates processors + java.util.Map tools = customClient.loadToolset("", null, null, false).get(); + Tool tool1 = tools.get("test-tool"); + assertNotNull(tool1); + + Field preField = Tool.class.getDeclaredField("preProcessors"); + preField.setAccessible(true); + java.util.List toolPre1 = + (java.util.List) preField.get(tool1); + assertEquals(1, toolPre1.size()); + org.junit.jupiter.api.Assertions.assertSame(mockPre, toolPre1.get(0)); + + Field postField = Tool.class.getDeclaredField("postProcessors"); + postField.setAccessible(true); + java.util.List toolPost1 = + (java.util.List) postField.get(tool1); + assertEquals(1, toolPost1.size()); + org.junit.jupiter.api.Assertions.assertSame(mockPost, toolPost1.get(0)); + + // 2. Verify loadTool propagates processors + Tool tool2 = customClient.loadTool("test-tool").get(); + assertNotNull(tool2); + + java.util.List toolPre2 = + (java.util.List) preField.get(tool2); + assertEquals(1, toolPre2.size()); + org.junit.jupiter.api.Assertions.assertSame(mockPre, toolPre2.get(0)); + + java.util.List toolPost2 = + (java.util.List) postField.get(tool2); + assertEquals(1, toolPost2.size()); + org.junit.jupiter.api.Assertions.assertSame(mockPost, toolPost2.get(0)); + } } From ab0a3489e8029ce3de3e9984a85931373588c09a Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Tue, 23 Jun 2026 16:32:47 +0530 Subject: [PATCH 15/20] style: format resolved files --- src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java b/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java index 6987d1b..89d734d 100644 --- a/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java +++ b/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java @@ -49,6 +49,7 @@ public final class McpToolboxClientImpl implements McpToolboxClient { /** Jackson ObjectMapper for JSON parsing. */ private final ObjectMapper objectMapper; + private final List preProcessors; private final List postProcessors; From cbfbcd4954f84c2016eeb635d38a95b8d86de0f0 Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Tue, 23 Jun 2026 16:36:22 +0530 Subject: [PATCH 16/20] style: format resolved ToolTest.java --- src/test/java/com/google/cloud/mcp/ToolTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/java/com/google/cloud/mcp/ToolTest.java b/src/test/java/com/google/cloud/mcp/ToolTest.java index 83a99d0..2a69ca5 100644 --- a/src/test/java/com/google/cloud/mcp/ToolTest.java +++ b/src/test/java/com/google/cloud/mcp/ToolTest.java @@ -562,6 +562,7 @@ void testToolDefinitionHints() { assertEquals(null, defWithoutHints.readOnlyHint()); assertEquals(null, defWithoutHints.destructiveHint()); } + @Test @SuppressWarnings("unchecked") void testExecute_withPreAndPostProcessors_modifiesArgsAndResult() throws Exception { From 382572c87cbac4d4fd12f486a82e34722870bdfc Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Tue, 23 Jun 2026 16:51:09 +0530 Subject: [PATCH 17/20] fix: resolve Javadoc warnings and errors on pre-post processors --- .../cloud/mcp/McpToolboxClientImpl.java | 16 ++++++++++---- .../com/google/cloud/mcp/ResolvedAuth.java | 5 +++++ .../com/google/cloud/mcp/ToolDefinition.java | 21 +++++++++++++++++-- 3 files changed, 36 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java b/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java index 89d734d..c5d254b 100644 --- a/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java +++ b/src/main/java/com/google/cloud/mcp/McpToolboxClientImpl.java @@ -65,9 +65,9 @@ public McpToolboxClientImpl(final Transport clientTransport) { /** * Constructs a new McpToolboxClientImpl. * - * @param clientTransport The underlying MCP transport layer. - * @param clientHeaders Fallback headers for deprecated constructor compatibility. - * @param provider Fallback provider for deprecated constructor compatibility. + * @param transport The underlying MCP transport layer. + * @param headers Fallback headers for deprecated constructor compatibility. + * @param credentialsProvider Fallback provider for deprecated constructor compatibility. */ @Deprecated public McpToolboxClientImpl( @@ -148,7 +148,15 @@ private static CredentialsProvider apiKeyToProvider(final String apiKey) { return () -> CompletableFuture.completedFuture(bearerKey); } - /** Primary constructor for McpToolboxClientImpl. */ + /** + * Primary constructor for McpToolboxClientImpl. + * + * @param transport The underlying MCP transport layer. + * @param headers Default HTTP headers. + * @param credentialsProvider Provider for credentials. + * @param preProcessors List of pre-processors. + * @param postProcessors List of post-processors. + */ public McpToolboxClientImpl( Transport transport, Map headers, diff --git a/src/main/java/com/google/cloud/mcp/ResolvedAuth.java b/src/main/java/com/google/cloud/mcp/ResolvedAuth.java index a3feae2..017d7e7 100644 --- a/src/main/java/com/google/cloud/mcp/ResolvedAuth.java +++ b/src/main/java/com/google/cloud/mcp/ResolvedAuth.java @@ -22,6 +22,11 @@ public final class ResolvedAuth { private final Map tokens; + /** + * Constructs a new ResolvedAuth. + * + * @param tokens The map of resolved auth tokens. + */ public ResolvedAuth(Map tokens) { Map copy = new java.util.HashMap<>(); if (tokens != null) { diff --git a/src/main/java/com/google/cloud/mcp/ToolDefinition.java b/src/main/java/com/google/cloud/mcp/ToolDefinition.java index 9277d08..ac8e60b 100644 --- a/src/main/java/com/google/cloud/mcp/ToolDefinition.java +++ b/src/main/java/com/google/cloud/mcp/ToolDefinition.java @@ -25,6 +25,9 @@ * * @param description A description of what the tool does. * @param parameters A list of parameters the tool accepts. + * @param authRequired List of auth services required by the tool. + * @param readOnlyHint Hint indicating whether the tool is read-only. + * @param destructiveHint Hint indicating whether the tool is destructive. */ @JsonIgnoreProperties(ignoreUnknown = true) public record ToolDefinition( @@ -34,7 +37,13 @@ public record ToolDefinition( Boolean readOnlyHint, Boolean destructiveHint) { - /** Backward-compatible constructor. */ + /** + * Backward-compatible constructor. + * + * @param description A description of what the tool does. + * @param parameters A list of parameters the tool accepts. + * @param authRequired List of auth services required. + */ public ToolDefinition(String description, List parameters, List authRequired) { this(description, parameters, authRequired, null, null); } @@ -58,7 +67,15 @@ public record Parameter( List authSources, // Maps services to parameters @JsonProperty("default") Object defaultValue) { - /** Backward-compatible constructor. */ + /** + * Backward-compatible constructor. + * + * @param name The name of the parameter. + * @param type The type of the parameter. + * @param required Whether the parameter is required. + * @param description A description of the parameter. + * @param authSources Authentication sources list. + */ public Parameter( String name, String type, boolean required, String description, List authSources) { this(name, type, required, description, authSources, null); From 561072cd303cd2b61314cacf7239ffdae9fedf40 Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Wed, 24 Jun 2026 11:00:05 +0530 Subject: [PATCH 18/20] test: add unit tests to achieve 100% coverage on Tool and McpToolboxClientBuilder --- .../mcp/McpToolboxClientBuilderTest.java | 16 +++++ .../java/com/google/cloud/mcp/ToolTest.java | 69 +++++++++++++++++++ 2 files changed, 85 insertions(+) diff --git a/src/test/java/com/google/cloud/mcp/McpToolboxClientBuilderTest.java b/src/test/java/com/google/cloud/mcp/McpToolboxClientBuilderTest.java index bbd2d66..324ef22 100644 --- a/src/test/java/com/google/cloud/mcp/McpToolboxClientBuilderTest.java +++ b/src/test/java/com/google/cloud/mcp/McpToolboxClientBuilderTest.java @@ -144,4 +144,20 @@ void testEmptyApiKey_TreatedAsNoKey() throws Exception { (CompletableFuture) getAuthHeaderMethod.invoke(client); assertNull(future.join()); } + + @Test + void testProcessorsConfiguration() { + ToolPreProcessor pre = (name, args) -> CompletableFuture.completedFuture(args); + ToolPostProcessor post = (name, result) -> CompletableFuture.completedFuture(result); + + McpToolboxClient client = + McpToolboxClient.builder() + .baseUrl("http://localhost:8080") + .preProcessor(pre) + .preProcessor(null) + .postProcessor(post) + .postProcessor(null) + .build(); + assertNotNull(client); + } } diff --git a/src/test/java/com/google/cloud/mcp/ToolTest.java b/src/test/java/com/google/cloud/mcp/ToolTest.java index 2a69ca5..4762d2b 100644 --- a/src/test/java/com/google/cloud/mcp/ToolTest.java +++ b/src/test/java/com/google/cloud/mcp/ToolTest.java @@ -649,4 +649,73 @@ void testExecute_preProcessorException_failsFutureWithoutInvokingClient() { verify(mockClient, never()).invokeTool(eq("test_tool"), anyMap(), anyMap()); verify(mockClient, never()).invokeTool(eq("test_tool"), anyMap()); } + + @Test + void testDefaultValueDeepCloning_withList() throws Exception { + McpToolboxClient mockClient = mock(McpToolboxClient.class); + + List complexDefault = new ArrayList<>(); + complexDefault.add("item1"); + complexDefault.add(Map.of("nestedKey", "nestedValue")); + + ToolDefinition.Parameter paramWithDefault = + new ToolDefinition.Parameter("param1", "array", false, "A parameter", null, complexDefault); + + ToolDefinition def = new ToolDefinition("A test tool", List.of(paramWithDefault), null); + + Tool tool = new Tool("testTool", def, mockClient); + + when(mockClient.invokeTool(eq("testTool"), any(), any())) + .thenReturn( + CompletableFuture.completedFuture(new ToolResult(Collections.emptyList(), false))); + + Map args = new HashMap<>(); + CompletableFuture future = tool.execute(args); + future.join(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> argsCaptor = ArgumentCaptor.forClass(Map.class); + verify(mockClient).invokeTool(eq("testTool"), argsCaptor.capture(), any()); + + Map capturedArgs = argsCaptor.getValue(); + @SuppressWarnings("unchecked") + List injectedDefault = (List) capturedArgs.get("param1"); + + // Mutate the injected list + injectedDefault.set(0, "mutated_item"); + + // Ensure the original defaultValue stored in the definition remains untouched + @SuppressWarnings("unchecked") + List defValueInDefinition = (List) def.parameters().get(0).defaultValue(); + assertEquals( + "item1", + defValueInDefinition.get(0), + "The default value in definition must remain unmutated"); + } + + @Test + void testValidateAndSanitizeArgs_requiredParameterProvided() throws Exception { + List params = + List.of(new ToolDefinition.Parameter("p-required", "string", true, "desc", List.of())); + ToolDefinition def = new ToolDefinition("test-tool", params, List.of()); + McpToolboxClient client = mock(McpToolboxClient.class); + when(client.invokeTool(anyString(), anyMap(), anyMap())) + .thenReturn(CompletableFuture.completedFuture(new ToolResult(List.of(), false))); + + Tool tool = new Tool("test-tool", def, client); + tool.execute(Map.of("p-required", "provided-value")).join(); // should succeed + } + + @Test + void testValidateAndSanitizeArgs_nullTypeWithNonNullValue() throws Exception { + List params = + List.of(new ToolDefinition.Parameter("p-no-type", null, false, "desc", List.of())); + ToolDefinition def = new ToolDefinition("test-tool", params, List.of()); + McpToolboxClient client = mock(McpToolboxClient.class); + when(client.invokeTool(anyString(), anyMap(), anyMap())) + .thenReturn(CompletableFuture.completedFuture(new ToolResult(List.of(), false))); + + Tool tool = new Tool("test-tool", def, client); + tool.execute(Map.of("p-no-type", "some-value")).join(); // should succeed without checking type + } } From 685360173f3629cf5226d9be6b82c4b2b8badaf2 Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Wed, 24 Jun 2026 11:11:39 +0530 Subject: [PATCH 19/20] refactor: add Timeout annotations and split ToolTest to ToolValidationTest --- .../java/com/google/cloud/mcp/ToolTest.java | 357 +--------------- .../google/cloud/mcp/ToolValidationTest.java | 386 ++++++++++++++++++ 2 files changed, 388 insertions(+), 355 deletions(-) create mode 100644 src/test/java/com/google/cloud/mcp/ToolValidationTest.java diff --git a/src/test/java/com/google/cloud/mcp/ToolTest.java b/src/test/java/com/google/cloud/mcp/ToolTest.java index 4762d2b..4c3682a 100644 --- a/src/test/java/com/google/cloud/mcp/ToolTest.java +++ b/src/test/java/com/google/cloud/mcp/ToolTest.java @@ -17,10 +17,8 @@ package com.google.cloud.mcp; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.eq; @@ -31,12 +29,10 @@ import static org.mockito.Mockito.when; import java.util.ArrayList; -import java.util.Collections; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; -import java.util.concurrent.CompletionException; import java.util.concurrent.ExecutionException; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -44,8 +40,10 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; import org.mockito.ArgumentCaptor; +@Timeout(10) class ToolTest { private ExecutorService pool; @@ -222,141 +220,6 @@ void testBindParamStaticAndSupplier() throws Exception { assertEquals("supplier-value", args.get("p-supplier")); } - @Test - void testValidateAndSanitizeArgs_nullsRemoved() throws Exception { - ToolDefinition def = new ToolDefinition("test-tool", List.of(), List.of()); - McpToolboxClient client = mock(McpToolboxClient.class); - - List> capturedArgs = new ArrayList<>(); - when(client.invokeTool(anyString(), anyMap(), anyMap())) - .thenAnswer( - inv -> { - capturedArgs.add(new HashMap<>(inv.getArgument(1))); - return CompletableFuture.completedFuture(new ToolResult(List.of(), false)); - }); - - Tool tool = new Tool("test-tool", def, client); - Map inputArgs = new HashMap<>(); - inputArgs.put("param-null", null); - inputArgs.put("param-valid", "value"); - - tool.execute(inputArgs).join(); - - assertEquals(1, capturedArgs.size()); - Map args = capturedArgs.get(0); - assertTrue(args.containsKey("param-valid")); - assertFalse(args.containsKey("param-null")); - } - - @Test - void testValidateAndSanitizeArgs_missingRequired() { - List params = - List.of(new ToolDefinition.Parameter("p-required", "string", true, "desc", List.of())); - ToolDefinition def = new ToolDefinition("test-tool", params, List.of()); - McpToolboxClient client = mock(McpToolboxClient.class); - Tool tool = new Tool("test-tool", def, client); - - CompletionException exception = - org.junit.jupiter.api.Assertions.assertThrows( - CompletionException.class, () -> tool.execute(Map.of()).join()); - assertTrue(exception.getCause() instanceof IllegalArgumentException); - assertTrue( - exception.getCause().getMessage().contains("Missing required parameter 'p-required'")); - } - - @Test - void testValidateAndSanitizeArgs_typeMismatches() { - List params = - List.of( - new ToolDefinition.Parameter("p-string", "string", false, "desc", List.of()), - new ToolDefinition.Parameter("p-int", "integer", false, "desc", List.of()), - new ToolDefinition.Parameter("p-number", "number", false, "desc", List.of()), - new ToolDefinition.Parameter("p-bool", "boolean", false, "desc", List.of()), - new ToolDefinition.Parameter("p-array", "array", false, "desc", List.of()), - new ToolDefinition.Parameter("p-obj", "object", false, "desc", List.of())); - ToolDefinition def = new ToolDefinition("test-tool", params, List.of()); - McpToolboxClient client = mock(McpToolboxClient.class); - Tool tool = new Tool("test-tool", def, client); - - // Expected string, got integer - CompletionException ex1 = - org.junit.jupiter.api.Assertions.assertThrows( - CompletionException.class, () -> tool.execute(Map.of("p-string", 123)).join()); - assertTrue(ex1.getCause() instanceof IllegalArgumentException); - - // Expected integer, got string - CompletionException ex2 = - org.junit.jupiter.api.Assertions.assertThrows( - CompletionException.class, () -> tool.execute(Map.of("p-int", "not-an-int")).join()); - assertTrue(ex2.getCause() instanceof IllegalArgumentException); - - // Expected number, got string - CompletionException ex3 = - org.junit.jupiter.api.Assertions.assertThrows( - CompletionException.class, - () -> tool.execute(Map.of("p-number", "not-a-number")).join()); - assertTrue(ex3.getCause() instanceof IllegalArgumentException); - - // Expected boolean, got string - CompletionException ex4 = - org.junit.jupiter.api.Assertions.assertThrows( - CompletionException.class, - () -> tool.execute(Map.of("p-bool", "not-a-boolean")).join()); - assertTrue(ex4.getCause() instanceof IllegalArgumentException); - - // Expected array, got string - CompletionException ex5 = - org.junit.jupiter.api.Assertions.assertThrows( - CompletionException.class, - () -> tool.execute(Map.of("p-array", "not-an-array")).join()); - assertTrue(ex5.getCause() instanceof IllegalArgumentException); - - // Expected object, got string - CompletionException ex6 = - org.junit.jupiter.api.Assertions.assertThrows( - CompletionException.class, () -> tool.execute(Map.of("p-obj", "not-an-object")).join()); - assertTrue(ex6.getCause() instanceof IllegalArgumentException); - } - - @Test - void testValidateAndSanitizeArgs_typeMatches() throws Exception { - List params = - List.of( - new ToolDefinition.Parameter("p-string", "string", false, "desc", List.of()), - new ToolDefinition.Parameter("p-int", "integer", false, "desc", List.of()), - new ToolDefinition.Parameter("p-int-val", "integer", false, "desc", List.of()), - new ToolDefinition.Parameter("p-number", "number", false, "desc", List.of()), - new ToolDefinition.Parameter("p-bool", "boolean", false, "desc", List.of()), - new ToolDefinition.Parameter("p-array", "array", false, "desc", List.of()), - new ToolDefinition.Parameter("p-array-arr", "array", false, "desc", List.of()), - new ToolDefinition.Parameter("p-obj", "object", false, "desc", List.of())); - ToolDefinition def = new ToolDefinition("test-tool", params, List.of()); - McpToolboxClient client = mock(McpToolboxClient.class); - when(client.invokeTool(anyString(), anyMap(), anyMap())) - .thenReturn(CompletableFuture.completedFuture(new ToolResult(List.of(), false))); - - Tool tool = new Tool("test-tool", def, client); - tool.execute( - Map.of( - "p-string", - "valid-string", - "p-int", - 123L, - "p-int-val", - 123, - "p-number", - 4.56, - "p-bool", - true, - "p-array", - List.of("item"), - "p-array-arr", - new String[] {"item"}, - "p-obj", - Map.of("key", "val"))) - .join(); // should succeed without exceptions - } - @Test void testResolvedAuth_withNullParametersListInDefinition() { ToolDefinition def = new ToolDefinition("test-tool", null, List.of()); @@ -403,153 +266,6 @@ void testResolvedAuth_withNullKeysAndValuesInTokensMap() { assertTrue(!extraHeaders.containsKey("null_token")); } - @Test - void testValidateAndSanitizeArgs_customTypeMatch() throws Exception { - List params = - List.of( - new ToolDefinition.Parameter("p-custom", "custom-type-name", false, "desc", List.of())); - ToolDefinition def = new ToolDefinition("test-tool", params, List.of()); - McpToolboxClient client = mock(McpToolboxClient.class); - when(client.invokeTool(anyString(), anyMap(), anyMap())) - .thenReturn(CompletableFuture.completedFuture(new ToolResult(List.of(), false))); - - Tool tool = new Tool("test-tool", def, client); - tool.execute(Map.of("p-custom", "any-value")).join(); // should succeed - } - - @Test - void testValidateAndSanitizeArgs_withNullParameters() throws Exception { - ToolDefinition def = new ToolDefinition("test-tool", null, List.of()); - McpToolboxClient client = mock(McpToolboxClient.class); - when(client.invokeTool(anyString(), anyMap(), anyMap())) - .thenReturn(CompletableFuture.completedFuture(new ToolResult(List.of(), false))); - - Tool tool = new Tool("test-tool", def, client); - tool.execute(Map.of("any-param", "any-value")).join(); // should bypass validation loop safely - } - - @Test - void testDefaultValueInjection() throws Exception { - McpToolboxClient mockClient = mock(McpToolboxClient.class); - - ToolDefinition.Parameter paramWithDefault = - new ToolDefinition.Parameter( - "param1", "string", false, "A parameter", null, "default_value"); - ToolDefinition.Parameter paramNoDefault = - new ToolDefinition.Parameter("param2", "string", false, "Another parameter", null, null); - - ToolDefinition def = - new ToolDefinition("A test tool", List.of(paramWithDefault, paramNoDefault), null); - - Tool tool = new Tool("testTool", def, mockClient); - - when(mockClient.invokeTool(eq("testTool"), any(), any())) - .thenReturn( - CompletableFuture.completedFuture(new ToolResult(Collections.emptyList(), false))); - - Map args = new HashMap<>(); - args.put("param2", "provided_value"); - - CompletableFuture future = tool.execute(args); - future.join(); // Wait for execution - - @SuppressWarnings("unchecked") - ArgumentCaptor> argsCaptor = ArgumentCaptor.forClass(Map.class); - @SuppressWarnings("unchecked") - ArgumentCaptor> headersCaptor = ArgumentCaptor.forClass(Map.class); - - verify(mockClient).invokeTool(eq("testTool"), argsCaptor.capture(), headersCaptor.capture()); - - Map capturedArgs = argsCaptor.getValue(); - - assertEquals( - "default_value", - capturedArgs.get("param1"), - "Default value should be injected when not provided"); - assertEquals("provided_value", capturedArgs.get("param2"), "Provided value should be kept"); - } - - @Test - void testDefaultValueNotOverwritten() throws Exception { - McpToolboxClient mockClient = mock(McpToolboxClient.class); - - ToolDefinition.Parameter paramWithDefault = - new ToolDefinition.Parameter( - "param1", "string", false, "A parameter", null, "default_value"); - - ToolDefinition def = new ToolDefinition("A test tool", List.of(paramWithDefault), null); - - Tool tool = new Tool("testTool", def, mockClient); - - when(mockClient.invokeTool(eq("testTool"), any(), any())) - .thenReturn( - CompletableFuture.completedFuture(new ToolResult(Collections.emptyList(), false))); - - Map args = new HashMap<>(); - args.put("param1", "custom_value"); - - CompletableFuture future = tool.execute(args); - future.join(); // Wait for execution - - @SuppressWarnings("unchecked") - ArgumentCaptor> argsCaptor = ArgumentCaptor.forClass(Map.class); - @SuppressWarnings("unchecked") - ArgumentCaptor> headersCaptor = ArgumentCaptor.forClass(Map.class); - - verify(mockClient).invokeTool(eq("testTool"), argsCaptor.capture(), headersCaptor.capture()); - - Map capturedArgs = argsCaptor.getValue(); - - assertEquals( - "custom_value", - capturedArgs.get("param1"), - "Provided value should not be overwritten by default value"); - } - - @Test - void testDefaultValueDeepCloning() throws Exception { - McpToolboxClient mockClient = mock(McpToolboxClient.class); - - Map complexDefault = new HashMap<>(); - complexDefault.put("key", "value"); - - ToolDefinition.Parameter paramWithDefault = - new ToolDefinition.Parameter( - "param1", "object", false, "A parameter", null, complexDefault); - - ToolDefinition def = new ToolDefinition("A test tool", List.of(paramWithDefault), null); - - Tool tool = new Tool("testTool", def, mockClient); - - when(mockClient.invokeTool(eq("testTool"), any(), any())) - .thenReturn( - CompletableFuture.completedFuture(new ToolResult(Collections.emptyList(), false))); - - Map args = new HashMap<>(); - CompletableFuture future = tool.execute(args); - future.join(); - - @SuppressWarnings("unchecked") - ArgumentCaptor> argsCaptor = ArgumentCaptor.forClass(Map.class); - verify(mockClient).invokeTool(eq("testTool"), argsCaptor.capture(), any()); - - Map capturedArgs = argsCaptor.getValue(); - @SuppressWarnings("unchecked") - Map injectedDefault = (Map) capturedArgs.get("param1"); - - // Mutate the injected map - injectedDefault.put("key", "mutated_value"); - - // Ensure the original defaultValue stored in the definition remains untouched - @SuppressWarnings("unchecked") - Map defValueInDefinition = - (Map) def.parameters().get(0).defaultValue(); - assertEquals( - "value", - defValueInDefinition.get("key"), - "The default value in definition must remain unmutated"); - } - @Test void testToolDefinitionHints() { ToolDefinition defWithHints = @@ -649,73 +365,4 @@ void testExecute_preProcessorException_failsFutureWithoutInvokingClient() { verify(mockClient, never()).invokeTool(eq("test_tool"), anyMap(), anyMap()); verify(mockClient, never()).invokeTool(eq("test_tool"), anyMap()); } - - @Test - void testDefaultValueDeepCloning_withList() throws Exception { - McpToolboxClient mockClient = mock(McpToolboxClient.class); - - List complexDefault = new ArrayList<>(); - complexDefault.add("item1"); - complexDefault.add(Map.of("nestedKey", "nestedValue")); - - ToolDefinition.Parameter paramWithDefault = - new ToolDefinition.Parameter("param1", "array", false, "A parameter", null, complexDefault); - - ToolDefinition def = new ToolDefinition("A test tool", List.of(paramWithDefault), null); - - Tool tool = new Tool("testTool", def, mockClient); - - when(mockClient.invokeTool(eq("testTool"), any(), any())) - .thenReturn( - CompletableFuture.completedFuture(new ToolResult(Collections.emptyList(), false))); - - Map args = new HashMap<>(); - CompletableFuture future = tool.execute(args); - future.join(); - - @SuppressWarnings("unchecked") - ArgumentCaptor> argsCaptor = ArgumentCaptor.forClass(Map.class); - verify(mockClient).invokeTool(eq("testTool"), argsCaptor.capture(), any()); - - Map capturedArgs = argsCaptor.getValue(); - @SuppressWarnings("unchecked") - List injectedDefault = (List) capturedArgs.get("param1"); - - // Mutate the injected list - injectedDefault.set(0, "mutated_item"); - - // Ensure the original defaultValue stored in the definition remains untouched - @SuppressWarnings("unchecked") - List defValueInDefinition = (List) def.parameters().get(0).defaultValue(); - assertEquals( - "item1", - defValueInDefinition.get(0), - "The default value in definition must remain unmutated"); - } - - @Test - void testValidateAndSanitizeArgs_requiredParameterProvided() throws Exception { - List params = - List.of(new ToolDefinition.Parameter("p-required", "string", true, "desc", List.of())); - ToolDefinition def = new ToolDefinition("test-tool", params, List.of()); - McpToolboxClient client = mock(McpToolboxClient.class); - when(client.invokeTool(anyString(), anyMap(), anyMap())) - .thenReturn(CompletableFuture.completedFuture(new ToolResult(List.of(), false))); - - Tool tool = new Tool("test-tool", def, client); - tool.execute(Map.of("p-required", "provided-value")).join(); // should succeed - } - - @Test - void testValidateAndSanitizeArgs_nullTypeWithNonNullValue() throws Exception { - List params = - List.of(new ToolDefinition.Parameter("p-no-type", null, false, "desc", List.of())); - ToolDefinition def = new ToolDefinition("test-tool", params, List.of()); - McpToolboxClient client = mock(McpToolboxClient.class); - when(client.invokeTool(anyString(), anyMap(), anyMap())) - .thenReturn(CompletableFuture.completedFuture(new ToolResult(List.of(), false))); - - Tool tool = new Tool("test-tool", def, client); - tool.execute(Map.of("p-no-type", "some-value")).join(); // should succeed without checking type - } } diff --git a/src/test/java/com/google/cloud/mcp/ToolValidationTest.java b/src/test/java/com/google/cloud/mcp/ToolValidationTest.java new file mode 100644 index 0000000..bef9d1a --- /dev/null +++ b/src/test/java/com/google/cloud/mcp/ToolValidationTest.java @@ -0,0 +1,386 @@ +/* + * 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; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.mockito.ArgumentCaptor; + +@Timeout(10) +class ToolValidationTest { + + private McpToolboxClient mockClient; + + @BeforeEach + void setUp() { + mockClient = mock(McpToolboxClient.class); + } + + @Test + void testValidateAndSanitizeArgs_nullsRemoved() throws Exception { + ToolDefinition def = new ToolDefinition("test-tool", List.of(), List.of()); + + List> capturedArgs = new ArrayList<>(); + when(mockClient.invokeTool(anyString(), anyMap(), anyMap())) + .thenAnswer( + inv -> { + capturedArgs.add(new HashMap<>(inv.getArgument(1))); + return CompletableFuture.completedFuture(new ToolResult(List.of(), false)); + }); + + Tool tool = new Tool("test-tool", def, mockClient); + Map inputArgs = new HashMap<>(); + inputArgs.put("param-null", null); + inputArgs.put("param-valid", "value"); + + tool.execute(inputArgs).join(); + + assertEquals(1, capturedArgs.size()); + Map args = capturedArgs.get(0); + assertTrue(args.containsKey("param-valid")); + assertFalse(args.containsKey("param-null")); + } + + @Test + void testValidateAndSanitizeArgs_missingRequired() { + List params = + List.of(new ToolDefinition.Parameter("p-required", "string", true, "desc", List.of())); + ToolDefinition def = new ToolDefinition("test-tool", params, List.of()); + Tool tool = new Tool("test-tool", def, mockClient); + + CompletionException exception = + org.junit.jupiter.api.Assertions.assertThrows( + CompletionException.class, () -> tool.execute(Map.of()).join()); + assertTrue(exception.getCause() instanceof IllegalArgumentException); + assertTrue( + exception.getCause().getMessage().contains("Missing required parameter 'p-required'")); + } + + @Test + void testValidateAndSanitizeArgs_typeMismatches() { + List params = + List.of( + new ToolDefinition.Parameter("p-string", "string", false, "desc", List.of()), + new ToolDefinition.Parameter("p-int", "integer", false, "desc", List.of()), + new ToolDefinition.Parameter("p-number", "number", false, "desc", List.of()), + new ToolDefinition.Parameter("p-bool", "boolean", false, "desc", List.of()), + new ToolDefinition.Parameter("p-array", "array", false, "desc", List.of()), + new ToolDefinition.Parameter("p-obj", "object", false, "desc", List.of())); + ToolDefinition def = new ToolDefinition("test-tool", params, List.of()); + Tool tool = new Tool("test-tool", def, mockClient); + + // Expected string, got integer + CompletionException ex1 = + org.junit.jupiter.api.Assertions.assertThrows( + CompletionException.class, () -> tool.execute(Map.of("p-string", 123)).join()); + assertTrue(ex1.getCause() instanceof IllegalArgumentException); + + // Expected integer, got string + CompletionException ex2 = + org.junit.jupiter.api.Assertions.assertThrows( + CompletionException.class, () -> tool.execute(Map.of("p-int", "not-an-int")).join()); + assertTrue(ex2.getCause() instanceof IllegalArgumentException); + + // Expected number, got string + CompletionException ex3 = + org.junit.jupiter.api.Assertions.assertThrows( + CompletionException.class, + () -> tool.execute(Map.of("p-number", "not-a-number")).join()); + assertTrue(ex3.getCause() instanceof IllegalArgumentException); + + // Expected boolean, got string + CompletionException ex4 = + org.junit.jupiter.api.Assertions.assertThrows( + CompletionException.class, + () -> tool.execute(Map.of("p-bool", "not-a-boolean")).join()); + assertTrue(ex4.getCause() instanceof IllegalArgumentException); + + // Expected array, got string + CompletionException ex5 = + org.junit.jupiter.api.Assertions.assertThrows( + CompletionException.class, + () -> tool.execute(Map.of("p-array", "not-an-array")).join()); + assertTrue(ex5.getCause() instanceof IllegalArgumentException); + + // Expected object, got string + CompletionException ex6 = + org.junit.jupiter.api.Assertions.assertThrows( + CompletionException.class, () -> tool.execute(Map.of("p-obj", "not-an-object")).join()); + assertTrue(ex6.getCause() instanceof IllegalArgumentException); + } + + @Test + void testValidateAndSanitizeArgs_typeMatches() throws Exception { + List params = + List.of( + new ToolDefinition.Parameter("p-string", "string", false, "desc", List.of()), + new ToolDefinition.Parameter("p-int", "integer", false, "desc", List.of()), + new ToolDefinition.Parameter("p-int-val", "integer", false, "desc", List.of()), + new ToolDefinition.Parameter("p-number", "number", false, "desc", List.of()), + new ToolDefinition.Parameter("p-bool", "boolean", false, "desc", List.of()), + new ToolDefinition.Parameter("p-array", "array", false, "desc", List.of()), + new ToolDefinition.Parameter("p-array-arr", "array", false, "desc", List.of()), + new ToolDefinition.Parameter("p-obj", "object", false, "desc", List.of())); + ToolDefinition def = new ToolDefinition("test-tool", params, List.of()); + when(mockClient.invokeTool(anyString(), anyMap(), anyMap())) + .thenReturn(CompletableFuture.completedFuture(new ToolResult(List.of(), false))); + + Tool tool = new Tool("test-tool", def, mockClient); + tool.execute( + Map.of( + "p-string", + "valid-string", + "p-int", + 123L, + "p-int-val", + 123, + "p-number", + 4.56, + "p-bool", + true, + "p-array", + List.of("item"), + "p-array-arr", + new String[] {"item"}, + "p-obj", + Map.of("key", "val"))) + .join(); // should succeed without exceptions + } + + @Test + void testValidateAndSanitizeArgs_customTypeMatch() throws Exception { + List params = + List.of( + new ToolDefinition.Parameter("p-custom", "custom-type-name", false, "desc", List.of())); + ToolDefinition def = new ToolDefinition("test-tool", params, List.of()); + when(mockClient.invokeTool(anyString(), anyMap(), anyMap())) + .thenReturn(CompletableFuture.completedFuture(new ToolResult(List.of(), false))); + + Tool tool = new Tool("test-tool", def, mockClient); + tool.execute(Map.of("p-custom", "any-value")).join(); // should succeed + } + + @Test + void testValidateAndSanitizeArgs_withNullParameters() throws Exception { + ToolDefinition def = new ToolDefinition("test-tool", null, List.of()); + when(mockClient.invokeTool(anyString(), anyMap(), anyMap())) + .thenReturn(CompletableFuture.completedFuture(new ToolResult(List.of(), false))); + + Tool tool = new Tool("test-tool", def, mockClient); + tool.execute(Map.of("any-param", "any-value")).join(); // should bypass validation loop safely + } + + @Test + void testDefaultValueInjection() throws Exception { + ToolDefinition.Parameter paramWithDefault = + new ToolDefinition.Parameter( + "param1", "string", false, "A parameter", null, "default_value"); + ToolDefinition.Parameter paramNoDefault = + new ToolDefinition.Parameter("param2", "string", false, "Another parameter", null, null); + + ToolDefinition def = + new ToolDefinition("A test tool", List.of(paramWithDefault, paramNoDefault), null); + + Tool tool = new Tool("testTool", def, mockClient); + + when(mockClient.invokeTool(eq("testTool"), any(), any())) + .thenReturn( + CompletableFuture.completedFuture(new ToolResult(Collections.emptyList(), false))); + + Map args = new HashMap<>(); + args.put("param2", "provided_value"); + + CompletableFuture future = tool.execute(args); + future.join(); // Wait for execution + + @SuppressWarnings("unchecked") + ArgumentCaptor> argsCaptor = ArgumentCaptor.forClass(Map.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> headersCaptor = ArgumentCaptor.forClass(Map.class); + + verify(mockClient).invokeTool(eq("testTool"), argsCaptor.capture(), headersCaptor.capture()); + + Map capturedArgs = argsCaptor.getValue(); + + assertEquals( + "default_value", + capturedArgs.get("param1"), + "Default value should be injected when not provided"); + assertEquals("provided_value", capturedArgs.get("param2"), "Provided value should be kept"); + } + + @Test + void testDefaultValueNotOverwritten() throws Exception { + ToolDefinition.Parameter paramWithDefault = + new ToolDefinition.Parameter( + "param1", "string", false, "A parameter", null, "default_value"); + + ToolDefinition def = new ToolDefinition("A test tool", List.of(paramWithDefault), null); + + Tool tool = new Tool("testTool", def, mockClient); + + when(mockClient.invokeTool(eq("testTool"), any(), any())) + .thenReturn( + CompletableFuture.completedFuture(new ToolResult(Collections.emptyList(), false))); + + Map args = new HashMap<>(); + args.put("param1", "custom_value"); + + CompletableFuture future = tool.execute(args); + future.join(); // Wait for execution + + @SuppressWarnings("unchecked") + ArgumentCaptor> argsCaptor = ArgumentCaptor.forClass(Map.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> headersCaptor = ArgumentCaptor.forClass(Map.class); + + verify(mockClient).invokeTool(eq("testTool"), argsCaptor.capture(), headersCaptor.capture()); + + Map capturedArgs = argsCaptor.getValue(); + + assertEquals( + "custom_value", + capturedArgs.get("param1"), + "Provided value should not be overwritten by default value"); + } + + @Test + void testDefaultValueDeepCloning() throws Exception { + Map complexDefault = new HashMap<>(); + complexDefault.put("key", "value"); + + ToolDefinition.Parameter paramWithDefault = + new ToolDefinition.Parameter( + "param1", "object", false, "A parameter", null, complexDefault); + + ToolDefinition def = new ToolDefinition("A test tool", List.of(paramWithDefault), null); + + Tool tool = new Tool("testTool", def, mockClient); + + when(mockClient.invokeTool(eq("testTool"), any(), any())) + .thenReturn( + CompletableFuture.completedFuture(new ToolResult(Collections.emptyList(), false))); + + Map args = new HashMap<>(); + CompletableFuture future = tool.execute(args); + future.join(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> argsCaptor = ArgumentCaptor.forClass(Map.class); + verify(mockClient).invokeTool(eq("testTool"), argsCaptor.capture(), any()); + + Map capturedArgs = argsCaptor.getValue(); + @SuppressWarnings("unchecked") + Map injectedDefault = (Map) capturedArgs.get("param1"); + + // Mutate the injected map + injectedDefault.put("key", "mutated_value"); + + // Ensure the original defaultValue stored in the definition remains untouched + @SuppressWarnings("unchecked") + Map defValueInDefinition = + (Map) def.parameters().get(0).defaultValue(); + assertEquals( + "value", + defValueInDefinition.get("key"), + "The default value in definition must remain unmutated"); + } + + @Test + void testDefaultValueDeepCloning_withList() throws Exception { + List complexDefault = new ArrayList<>(); + complexDefault.add("item1"); + complexDefault.add(Map.of("nestedKey", "nestedValue")); + + ToolDefinition.Parameter paramWithDefault = + new ToolDefinition.Parameter("param1", "array", false, "A parameter", null, complexDefault); + + ToolDefinition def = new ToolDefinition("A test tool", List.of(paramWithDefault), null); + + Tool tool = new Tool("testTool", def, mockClient); + + when(mockClient.invokeTool(eq("testTool"), any(), any())) + .thenReturn( + CompletableFuture.completedFuture(new ToolResult(Collections.emptyList(), false))); + + Map args = new HashMap<>(); + CompletableFuture future = tool.execute(args); + future.join(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> argsCaptor = ArgumentCaptor.forClass(Map.class); + verify(mockClient).invokeTool(eq("testTool"), argsCaptor.capture(), any()); + + Map capturedArgs = argsCaptor.getValue(); + @SuppressWarnings("unchecked") + List injectedDefault = (List) capturedArgs.get("param1"); + + // Mutate the injected list + injectedDefault.set(0, "mutated_item"); + + // Ensure the original defaultValue stored in the definition remains untouched + @SuppressWarnings("unchecked") + List defValueInDefinition = (List) def.parameters().get(0).defaultValue(); + assertEquals( + "item1", + defValueInDefinition.get(0), + "The default value in definition must remain unmutated"); + } + + @Test + void testValidateAndSanitizeArgs_requiredParameterProvided() throws Exception { + List params = + List.of(new ToolDefinition.Parameter("p-required", "string", true, "desc", List.of())); + ToolDefinition def = new ToolDefinition("test-tool", params, List.of()); + when(mockClient.invokeTool(anyString(), anyMap(), anyMap())) + .thenReturn(CompletableFuture.completedFuture(new ToolResult(List.of(), false))); + + Tool tool = new Tool("test-tool", def, mockClient); + tool.execute(Map.of("p-required", "provided-value")).join(); // should succeed + } + + @Test + void testValidateAndSanitizeArgs_nullTypeWithNonNullValue() throws Exception { + List params = + List.of(new ToolDefinition.Parameter("p-no-type", null, false, "desc", List.of())); + ToolDefinition def = new ToolDefinition("test-tool", params, List.of()); + when(mockClient.invokeTool(anyString(), anyMap(), anyMap())) + .thenReturn(CompletableFuture.completedFuture(new ToolResult(List.of(), false))); + + Tool tool = new Tool("test-tool", def, mockClient); + tool.execute(Map.of("p-no-type", "some-value")).join(); // should succeed without checking type + } +} From 7dae6404efe86c87c3b042ac7386a7ae70ed09f9 Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Wed, 24 Jun 2026 11:16:05 +0530 Subject: [PATCH 20/20] chore: trigger conventionalcommits check