diff --git a/README.md b/README.md index b5aa640..454e7ab 100644 --- a/README.md +++ b/README.md @@ -216,15 +216,19 @@ public class App { For a detailed example, check the ExampleUsage.java file in the example folder of this repo. > [!NOTE] -> -> The SDK is Async-First, using Java's `CompletableFuture` to bridge both patterns naturally. -> - Asynchronous: Chain methods using `.thenCompose()`, `.thenAccept()`, and `.exceptionally()` for non-blocking execution. -> - If you prefer synchronous execution, simply call `.join()` on the result to block until completion. +> The SDK is async-first, but provides a pure synchronous blocking experience using `McpToolboxSyncClient`. +> - **Asynchronous Client (`McpToolboxClient`):** Chain methods using `.thenCompose()`, `.thenAccept()`, and `.exceptionally()`. +> - **Synchronous Client (`McpToolboxSyncClient`):** Call blocking methods directly. Under the hood, it manages threads safely and translates checked/execution exceptions into unchecked `McpToolboxException`s. + ```java -// Async (Non-blocking) -client.invokeTool("tool-name", args).thenAccept(result -> ...); -// Sync (Blocking) -ToolResult result = client.invokeTool("tool-name", args).join(); +// Create a Synchronous Client +McpToolboxSyncClient client = McpToolboxClient.builder() + .baseUrl("https://my-toolbox-service.a.run.app/mcp") + .buildSync(); + +// Invoke a tool synchronously +ToolResult result = client.invokeTool("get-toy-price", Map.of("description", "plush dinosaur")); +System.out.println("Output: " + result.content().get(0).text()); ``` ## Authentication diff --git a/integration.cloudbuild.yaml b/integration.cloudbuild.yaml index 200332a..fe6174f 100644 --- a/integration.cloudbuild.yaml +++ b/integration.cloudbuild.yaml @@ -16,11 +16,11 @@ steps: - id: Install library requirements name: 'maven:3.9.6-eclipse-temurin-17' entrypoint: 'mvn' - args: ['clean', 'install', '-DskipTests'] + args: ['-B', '-ntp', 'clean', 'install', '-DskipTests'] - id: Run integration tests name: 'maven:3.9.6-eclipse-temurin-17' entrypoint: 'mvn' - args: ['test'] + args: ['-B', '-ntp', 'test'] env: - GOOGLE_CLOUD_PROJECT=$PROJECT_ID - TOOLBOX_VERSION=${_TOOLBOX_VERSION} diff --git a/src/main/java/com/google/cloud/mcp/McpToolboxClient.java b/src/main/java/com/google/cloud/mcp/McpToolboxClient.java index ff79e04..30f6acc 100644 --- a/src/main/java/com/google/cloud/mcp/McpToolboxClient.java +++ b/src/main/java/com/google/cloud/mcp/McpToolboxClient.java @@ -175,6 +175,22 @@ interface Builder { */ Builder protocolVersion(ProtocolVersion protocolVersion); + /** + * Sets the connect timeout for the underlying HttpClient. + * + * @param connectTimeout The connect timeout. + * @return The builder instance. + */ + Builder connectTimeout(java.time.Duration connectTimeout); + + /** + * Sets the request timeout for every HTTP request. + * + * @param requestTimeout The request timeout. + * @return The builder instance. + */ + Builder requestTimeout(java.time.Duration requestTimeout); + /** * Sets a custom {@link java.net.http.HttpClient} for connection management. * @@ -191,11 +207,26 @@ interface Builder { */ Builder executor(java.util.concurrent.Executor executor); + /** + * Sets a custom Logger for telemetry and logs. + * + * @param logger The custom Logger. + * @return The builder instance. + */ + Builder logger(java.util.logging.Logger logger); + /** * Builds and returns a new {@link McpToolboxClient} instance. * * @return The new client instance. */ McpToolboxClient build(); + + /** + * Builds and returns a new {@link McpToolboxSyncClient} instance. + * + * @return The new synchronous client instance. + */ + McpToolboxSyncClient buildSync(); } } diff --git a/src/main/java/com/google/cloud/mcp/McpToolboxSyncClient.java b/src/main/java/com/google/cloud/mcp/McpToolboxSyncClient.java new file mode 100644 index 0000000..f50f1f1 --- /dev/null +++ b/src/main/java/com/google/cloud/mcp/McpToolboxSyncClient.java @@ -0,0 +1,112 @@ +/* + * 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.google.cloud.mcp.auth.AuthTokenGetter; +import com.google.cloud.mcp.exception.McpToolboxException; +import com.google.cloud.mcp.tool.Tool; +import com.google.cloud.mcp.tool.ToolDefinition; +import com.google.cloud.mcp.tool.ToolResult; +import java.util.Map; + +/** The synchronous blocking client for interacting with MCP Toolbox. */ +public interface McpToolboxSyncClient { + + /** + * Connects to the MCP Server and retrieves the list of all available tools. + * + * @return The map of Tool definitions (Key: Tool Name). + * @throws McpToolboxException if any error occurs. + */ + Map listTools(); + + /** + * Loads the toolset from the MCP Server. Alias for {@link #listTools()}. + * + * @return The map of Tool definitions (Key: Tool Name). + * @throws McpToolboxException if any error occurs. + */ + default Map loadToolset() { + return listTools(); + } + + /** + * Loads a specific toolset by name (if supported by server). + * + * @param toolsetName The name of the toolset to load. + * @return The map of Tool definitions (Key: Tool Name). + * @throws McpToolboxException if any error occurs. + */ + Map loadToolset(String toolsetName); + + /** + * Loads a toolset (or all tools if toolsetName is null) and applies bindings. + * + * @param toolsetName Name of the toolset to load (or null for all). + * @param paramBinds Map of Tool Name -> (Parameter Name -> Value). + * @param authBinds Map of Tool Name -> (Service -> Token Getter). + * @param strict Throws exception if bindings refer to non-existent tools. + * @return A Map of ready-to-use Tool objects. + * @throws McpToolboxException if any error occurs. + */ + Map loadToolset( + String toolsetName, + Map> paramBinds, + Map> authBinds, + boolean strict); + + /** + * Loads a specific tool definition and returns a smart Tool object. + * + * @param toolName The name of the tool to load. + * @return The Tool object. + * @throws McpToolboxException if any error occurs. + */ + Tool loadTool(String toolName); + + /** + * Loads a specific tool and registers authentication getters immediately. + * + * @param toolName The name of the tool. + * @param authTokenGetters A map of Service Name -> Token Getter Function. + * @return The Tool object. + * @throws McpToolboxException if any error occurs. + */ + Tool loadTool(String toolName, Map authTokenGetters); + + /** + * Low-level invocation method. + * + * @param toolName The name of the tool to invoke. + * @param arguments The arguments to pass to the tool. + * @return The result of the tool invocation. + * @throws McpToolboxException if any error occurs. + */ + ToolResult invokeTool(String toolName, Map arguments); + + /** + * Low-level invocation method with explicit headers. + * + * @param toolName The name of the tool to invoke. + * @param arguments The arguments to pass to the tool. + * @param extraHeaders Additional HTTP headers to include in the request. + * @return The result of the tool invocation. + * @throws McpToolboxException if any error occurs. + */ + ToolResult invokeTool( + String toolName, Map arguments, Map extraHeaders); +} diff --git a/src/main/java/com/google/cloud/mcp/client/HttpMcpToolboxSyncClient.java b/src/main/java/com/google/cloud/mcp/client/HttpMcpToolboxSyncClient.java new file mode 100644 index 0000000..49c95e7 --- /dev/null +++ b/src/main/java/com/google/cloud/mcp/client/HttpMcpToolboxSyncClient.java @@ -0,0 +1,133 @@ +/* + * 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.client; + +import com.google.cloud.mcp.McpToolboxClient; +import com.google.cloud.mcp.McpToolboxSyncClient; +import com.google.cloud.mcp.auth.AuthTokenGetter; +import com.google.cloud.mcp.exception.McpToolboxException; +import com.google.cloud.mcp.tool.Tool; +import com.google.cloud.mcp.tool.ToolDefinition; +import com.google.cloud.mcp.tool.ToolResult; +import java.util.Map; +import java.util.concurrent.CancellationException; +import java.util.concurrent.CompletionException; + +/** + * Synchronous client wrapper that delegates to an async {@link McpToolboxClient} and blocks the + * calling thread, unwrapping exceptions. + */ +public final class HttpMcpToolboxSyncClient implements McpToolboxSyncClient { + /** The underlying asynchronous client to delegate to. */ + private final McpToolboxClient delegate; + + /** + * Constructs a new HttpMcpToolboxSyncClient wrapping the given async client. + * + * @param client The async client to delegate to. + */ + public HttpMcpToolboxSyncClient(final McpToolboxClient client) { + if (client == null) { + throw new IllegalArgumentException("Delegate client cannot be null"); + } + this.delegate = client; + } + + @Override + public Map listTools() { + try { + return delegate.listTools().join(); + } catch (CompletionException | CancellationException e) { + throw unwrapException(e); + } + } + + @Override + public Map loadToolset(final String toolsetName) { + try { + return delegate.loadToolset(toolsetName).join(); + } catch (CompletionException | CancellationException e) { + throw unwrapException(e); + } + } + + @Override + public Map loadToolset( + final String toolsetName, + final Map> paramBinds, + final Map> authBinds, + final boolean strict) { + try { + return delegate.loadToolset(toolsetName, paramBinds, authBinds, strict).join(); + } catch (CompletionException | CancellationException e) { + throw unwrapException(e); + } + } + + @Override + public Tool loadTool(final String toolName) { + try { + return delegate.loadTool(toolName).join(); + } catch (CompletionException | CancellationException e) { + throw unwrapException(e); + } + } + + @Override + public Tool loadTool(final String toolName, final Map authTokenGetters) { + try { + return delegate.loadTool(toolName, authTokenGetters).join(); + } catch (CompletionException | CancellationException e) { + throw unwrapException(e); + } + } + + @Override + public ToolResult invokeTool(final String toolName, final Map arguments) { + try { + return delegate.invokeTool(toolName, arguments).join(); + } catch (CompletionException | CancellationException e) { + throw unwrapException(e); + } + } + + @Override + public ToolResult invokeTool( + final String toolName, + final Map arguments, + final Map extraHeaders) { + try { + return delegate.invokeTool(toolName, arguments, extraHeaders).join(); + } catch (CompletionException | CancellationException e) { + throw unwrapException(e); + } + } + + private RuntimeException unwrapException(final Throwable e) { + final Throwable cause = e.getCause(); + if (cause == null) { + return new McpToolboxException(e); + } + if (cause instanceof McpToolboxException) { + return (McpToolboxException) cause; + } + if (cause instanceof IllegalArgumentException) { + return (IllegalArgumentException) cause; + } + return new McpToolboxException(cause.getMessage(), cause); + } +} diff --git a/src/main/java/com/google/cloud/mcp/client/McpToolboxClientBuilder.java b/src/main/java/com/google/cloud/mcp/client/McpToolboxClientBuilder.java index 0b66c13..a9ebaec 100644 --- a/src/main/java/com/google/cloud/mcp/client/McpToolboxClientBuilder.java +++ b/src/main/java/com/google/cloud/mcp/client/McpToolboxClientBuilder.java @@ -17,6 +17,7 @@ package com.google.cloud.mcp.client; import com.google.cloud.mcp.McpToolboxClient; +import com.google.cloud.mcp.McpToolboxSyncClient; import com.google.cloud.mcp.ProtocolVersion; import com.google.cloud.mcp.auth.CredentialsProvider; import com.google.cloud.mcp.tool.ToolPostProcessor; @@ -38,8 +39,11 @@ public final class McpToolboxClientBuilder implements McpToolboxClient.Builder { private final List preProcessors = new ArrayList<>(); private final List postProcessors = new ArrayList<>(); private ProtocolVersion protocolVersion; + private java.time.Duration connectTimeout; + private java.time.Duration requestTimeout; private java.net.http.HttpClient httpClient; private java.util.concurrent.Executor executor; + private java.util.logging.Logger logger; /** Constructs a new McpToolboxClientBuilder. */ public McpToolboxClientBuilder() {} @@ -92,6 +96,18 @@ public McpToolboxClient.Builder protocolVersion(ProtocolVersion protocolVersion) return this; } + @Override + public McpToolboxClient.Builder connectTimeout(java.time.Duration connectTimeout) { + this.connectTimeout = connectTimeout; + return this; + } + + @Override + public McpToolboxClient.Builder requestTimeout(java.time.Duration requestTimeout) { + this.requestTimeout = requestTimeout; + return this; + } + @Override public McpToolboxClient.Builder httpClient(java.net.http.HttpClient httpClient) { this.httpClient = httpClient; @@ -104,6 +120,12 @@ public McpToolboxClient.Builder executor(java.util.concurrent.Executor executor) return this; } + @Override + public McpToolboxClient.Builder logger(java.util.logging.Logger logger) { + this.logger = logger; + return this; + } + @Override public McpToolboxClient build() { if (baseUrl == null || baseUrl.isEmpty()) { @@ -137,8 +159,16 @@ public McpToolboxClient build() { resolvedProvider, this.protocolVersion, this.httpClient, - this.executor); + this.executor, + this.connectTimeout, + this.requestTimeout, + this.logger); return new McpToolboxClientImpl( transport, this.headers, resolvedProvider, preProcessors, postProcessors); } + + @Override + public McpToolboxSyncClient buildSync() { + return new HttpMcpToolboxSyncClient(build()); + } } diff --git a/src/main/java/com/google/cloud/mcp/exception/McpProtocolException.java b/src/main/java/com/google/cloud/mcp/exception/McpProtocolException.java new file mode 100644 index 0000000..c944ecf --- /dev/null +++ b/src/main/java/com/google/cloud/mcp/exception/McpProtocolException.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.cloud.mcp.exception; + +/** Exception thrown when JSON-RPC or protocol level errors occur. */ +public class McpProtocolException extends McpToolboxException { + + /** + * Constructs a new McpProtocolException with the specified detail message. + * + * @param message The detail message. + */ + public McpProtocolException(final String message) { + super(message); + } + + /** + * Constructs a new McpProtocolException with the specified message and cause. + * + * @param message The detail message. + * @param cause The cause of the exception. + */ + public McpProtocolException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/google/cloud/mcp/exception/McpToolboxException.java b/src/main/java/com/google/cloud/mcp/exception/McpToolboxException.java new file mode 100644 index 0000000..5d9242e --- /dev/null +++ b/src/main/java/com/google/cloud/mcp/exception/McpToolboxException.java @@ -0,0 +1,49 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.cloud.mcp.exception; + +/** Base exception class for all MCP Toolbox SDK errors. */ +public class McpToolboxException extends RuntimeException { + + /** + * Constructs a new McpToolboxException with the specified detail message. + * + * @param message The detail message. + */ + public McpToolboxException(final String message) { + super(message); + } + + /** + * Constructs a new McpToolboxException with the specified message and cause. + * + * @param message The detail message. + * @param cause The cause of the exception. + */ + public McpToolboxException(final String message, final Throwable cause) { + super(message, cause); + } + + /** + * Constructs a new McpToolboxException with the specified cause. + * + * @param cause The cause of the exception. + */ + public McpToolboxException(final Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/google/cloud/mcp/exception/McpTransportException.java b/src/main/java/com/google/cloud/mcp/exception/McpTransportException.java new file mode 100644 index 0000000..16a0270 --- /dev/null +++ b/src/main/java/com/google/cloud/mcp/exception/McpTransportException.java @@ -0,0 +1,76 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.cloud.mcp.exception; + +/** Exception thrown when communication or transport level errors occur. */ +public class McpTransportException extends McpToolboxException { + /** The HTTP status code associated with this error, or -1 if not applicable. */ + private final int statusCode; + + /** + * Constructs a new McpTransportException with the specified detail message. + * + * @param message The detail message. + */ + public McpTransportException(final String message) { + super(message); + this.statusCode = -1; + } + + /** + * Constructs a new McpTransportException with message and status code. + * + * @param message The detail message. + * @param statusCode The HTTP status code. + */ + public McpTransportException(final String message, final int statusCode) { + super(message); + this.statusCode = statusCode; + } + + /** + * Constructs a new McpTransportException with message and cause. + * + * @param message The detail message. + * @param cause The cause of the exception. + */ + public McpTransportException(final String message, final Throwable cause) { + super(message, cause); + this.statusCode = -1; + } + + /** + * Constructs a new McpTransportException with message, status code, and cause. + * + * @param message The detail message. + * @param statusCode The HTTP status code. + * @param cause The cause of the exception. + */ + public McpTransportException(final String message, final int statusCode, final Throwable cause) { + super(message, cause); + this.statusCode = statusCode; + } + + /** + * Returns the HTTP status code associated with this error, or -1 if not applicable. + * + * @return The HTTP status code. + */ + public int getStatusCode() { + return statusCode; + } +} diff --git a/src/main/java/com/google/cloud/mcp/exception/ToolExecutionException.java b/src/main/java/com/google/cloud/mcp/exception/ToolExecutionException.java new file mode 100644 index 0000000..e8525f2 --- /dev/null +++ b/src/main/java/com/google/cloud/mcp/exception/ToolExecutionException.java @@ -0,0 +1,40 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.cloud.mcp.exception; + +/** Exception thrown when tool lookup, validation, or invocation fails. */ +public class ToolExecutionException extends McpToolboxException { + + /** + * Constructs a new ToolExecutionException with the specified detail message. + * + * @param message The detail message. + */ + public ToolExecutionException(final String message) { + super(message); + } + + /** + * Constructs a new ToolExecutionException with the specified message/cause. + * + * @param message The detail message. + * @param cause The cause of the exception. + */ + public ToolExecutionException(final String message, final Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/google/cloud/mcp/tool/Tool.java b/src/main/java/com/google/cloud/mcp/tool/Tool.java index 173c58a..fa0296e 100644 --- a/src/main/java/com/google/cloud/mcp/tool/Tool.java +++ b/src/main/java/com/google/cloud/mcp/tool/Tool.java @@ -19,6 +19,7 @@ import com.google.cloud.mcp.McpToolboxClient; import com.google.cloud.mcp.auth.AuthResolver; import com.google.cloud.mcp.auth.AuthTokenGetter; +import com.google.cloud.mcp.exception.McpToolboxException; import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; @@ -216,6 +217,32 @@ public Tool addPostProcessor(final ToolPostProcessor processor) { newPost); } + /** + * Synchronously executes the tool with the provided arguments. + * + * @param args The arguments for the tool invocation. + * @return The result of the tool execution. + * @throws McpToolboxException if execution fails. + */ + public ToolResult executeSync(final Map args) { + try { + return execute(args).join(); + } catch (java.util.concurrent.CompletionException + | java.util.concurrent.CancellationException e) { + Throwable cause = e.getCause(); + if (cause instanceof McpToolboxException) { + throw (McpToolboxException) cause; + } + if (cause instanceof IllegalArgumentException) { + throw (IllegalArgumentException) cause; + } + if (cause != null) { + throw new McpToolboxException(cause.getMessage(), cause); + } + throw new McpToolboxException(e); + } + } + /** * Executes the tool with the provided arguments, applying any bound parameters and resolving * authentication tokens. diff --git a/src/main/java/com/google/cloud/mcp/transport/BaseMcpTransport.java b/src/main/java/com/google/cloud/mcp/transport/BaseMcpTransport.java index a09b383..818b1a5 100644 --- a/src/main/java/com/google/cloud/mcp/transport/BaseMcpTransport.java +++ b/src/main/java/com/google/cloud/mcp/transport/BaseMcpTransport.java @@ -36,22 +36,60 @@ import java.util.concurrent.CompletableFuture; import java.util.logging.Logger; +/** + * Base class for HTTP-based MCP transports providing common functionality like session tracking, + * header merging, and credentials resolution. + */ public abstract class BaseMcpTransport implements Transport { + /** Default static logger for BaseMcpTransport. */ protected static final Logger logger = Logger.getLogger(BaseMcpTransport.class.getName()); + + /** Warning message displayed when using unencrypted HTTP connections. */ protected static final String HTTP_WARNING = "This connection is using HTTP. To prevent credential exposure, please ensure all" + " communication is sent over HTTPS."; + /** The base URL of the MCP service. */ protected final String baseUrl; + + /** Client headers configured for the transport. */ protected final Map clientHeaders; + + /** The credentials provider for dynamic authorization. */ protected final CredentialsProvider credentialsProvider; + + /** The HTTP client used for requests. */ protected final HttpClient httpClient; + + /** The ObjectMapper for JSON serialization. */ protected final ObjectMapper objectMapper; + + /** The preferred protocol version. */ protected final ProtocolVersion preferredProtocolVersion; + + /** Lock object to synchronize initialization. */ protected final Object initLock = new Object(); + + /** Future indicating the status of initialization. */ protected CompletableFuture initFuture; + /** The request timeout for HTTP requests. */ + protected final java.time.Duration requestTimeout; + + /** The logger used for active transport logging. */ + protected final Logger activeLogger; + + /** + * Constructs a new BaseMcpTransport. + * + * @param baseUrl The base URL. + * @param clientHeaders The client headers. + * @param credentialsProvider The credentials provider. + * @param preferredProtocolVersion The preferred protocol version. + * @param httpClient The HTTP client. + * @param executor The executor. + */ protected BaseMcpTransport( final String baseUrl, final Map clientHeaders, @@ -59,6 +97,41 @@ protected BaseMcpTransport( final ProtocolVersion preferredProtocolVersion, final HttpClient httpClient, final java.util.concurrent.Executor executor) { + this( + baseUrl, + clientHeaders, + credentialsProvider, + preferredProtocolVersion, + httpClient, + executor, + null, + null, + null); + } + + /** + * Constructs a new BaseMcpTransport with timeouts and custom logger. + * + * @param baseUrl The base URL. + * @param clientHeaders The client headers. + * @param credentialsProvider The credentials provider. + * @param preferredProtocolVersion The preferred protocol version. + * @param httpClient The HTTP client. + * @param executor The executor. + * @param connectTimeout The connection timeout. + * @param requestTimeout The request timeout. + * @param logger The custom logger. + */ + protected BaseMcpTransport( + final String baseUrl, + final Map clientHeaders, + final CredentialsProvider credentialsProvider, + final ProtocolVersion preferredProtocolVersion, + final HttpClient httpClient, + final java.util.concurrent.Executor executor, + final java.time.Duration connectTimeout, + final java.time.Duration requestTimeout, + final Logger logger) { if (baseUrl == null || baseUrl.isEmpty()) { throw new IllegalArgumentException("Base URL must be provided"); } @@ -78,12 +151,14 @@ protected BaseMcpTransport( HttpClient.Builder builder = HttpClient.newBuilder() .cookieHandler(new java.net.CookieManager()) - .connectTimeout(Duration.ofSeconds(10)); + .connectTimeout(connectTimeout != null ? connectTimeout : Duration.ofSeconds(10)); if (executor != null) { builder.executor(executor); } this.httpClient = builder.build(); } + this.requestTimeout = requestTimeout; + this.activeLogger = logger != null ? logger : BaseMcpTransport.logger; this.objectMapper = new ObjectMapper(); } @@ -186,9 +261,21 @@ final CompletableFuture ensureInitialized(final Map extraM } } + /** + * Performs the version-specific initialization handshake. + * + * @param authHeader The authorization header value, if present. + * @param handshakeHeaders The resolved headers for the handshake. + * @return A CompletableFuture that completes when initialization is done. + */ protected abstract CompletableFuture performInitialization( final String authHeader, final Map handshakeHeaders); + /** + * Applies protocol-specific headers to the request builder. + * + * @param builder The HTTP request builder. + */ protected abstract void applyProtocolHeaders(final HttpRequest.Builder builder); @Override @@ -196,7 +283,7 @@ public final CompletableFuture listTools( final String toolsetName, final Map metadata) { if (this.baseUrl.toLowerCase(java.util.Locale.ROOT).startsWith("http://") && !metadata.isEmpty()) { - logger.warning(HTTP_WARNING); + activeLogger.warning(HTTP_WARNING); } return ensureInitialized(metadata) .thenCompose(v -> mergeHeaders(metadata)) @@ -211,6 +298,9 @@ public final CompletableFuture listTools( HttpRequest.newBuilder() .uri(URI.create(url)) .POST(HttpRequest.BodyPublishers.ofString(body)); + if (requestTimeout != null) { + req.timeout(requestTimeout); + } mergedHeaders.forEach(req::setHeader); applyProtocolHeaders(req); @@ -230,7 +320,7 @@ public final CompletableFuture invokeTool( final Map metadata) { if (this.baseUrl.toLowerCase(java.util.Locale.ROOT).startsWith("http://") && !metadata.isEmpty()) { - logger.warning(HTTP_WARNING); + activeLogger.warning(HTTP_WARNING); } return ensureInitialized(metadata) .thenCompose(v -> mergeHeaders(metadata)) @@ -247,6 +337,9 @@ public final CompletableFuture invokeTool( .uri(URI.create(baseUrl)) .POST(HttpRequest.BodyPublishers.ofString(requestBody)); + if (requestTimeout != null) { + requestBuilder.timeout(requestTimeout); + } mergedHeaders.forEach(requestBuilder::setHeader); applyProtocolHeaders(requestBuilder); diff --git a/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransport.java b/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransport.java index b034a87..863e6ed 100644 --- a/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransport.java +++ b/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransport.java @@ -19,8 +19,10 @@ import com.google.cloud.mcp.ProtocolVersion; import com.google.cloud.mcp.auth.CredentialsProvider; import java.net.http.HttpClient; +import java.time.Duration; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.logging.Logger; /** Default HTTP transport implementation routing requests to version-specific handlers. */ public final class HttpMcpTransport implements Transport { @@ -95,6 +97,41 @@ public HttpMcpTransport( final ProtocolVersion preferredProtocolVersion, final HttpClient httpClient, final java.util.concurrent.Executor executor) { + this( + baseUrl, + clientHeaders, + credentialsProvider, + preferredProtocolVersion, + httpClient, + executor, + null, + null, + null); + } + + /** + * Constructs a new HttpMcpTransport with full configuration. + * + * @param baseUrl The base URL of the MCP service. + * @param clientHeaders Optional headers to include in every request. + * @param credentialsProvider Optional provider for auth credentials. + * @param preferredProtocolVersion Optional preferred protocol version. + * @param httpClient Optional HttpClient instance. + * @param executor Optional Executor for handling async requests. + * @param connectTimeout Optional connection timeout. + * @param requestTimeout Optional request timeout. + * @param logger Optional Logger instance. + */ + public HttpMcpTransport( + final String baseUrl, + final Map clientHeaders, + final CredentialsProvider credentialsProvider, + final ProtocolVersion preferredProtocolVersion, + final HttpClient httpClient, + final java.util.concurrent.Executor executor, + final Duration connectTimeout, + final Duration requestTimeout, + final Logger logger) { final ProtocolVersion version = preferredProtocolVersion != null ? preferredProtocolVersion @@ -104,34 +141,73 @@ public HttpMcpTransport( case VERSION_2025_11_25: this.delegate = new HttpMcpTransportV20251125( - baseUrl, clientHeaders, credentialsProvider, httpClient, executor); + baseUrl, + clientHeaders, + credentialsProvider, + httpClient, + executor, + connectTimeout, + requestTimeout, + logger); break; case VERSION_2025_06_18: this.delegate = new HttpMcpTransportV20250618( - baseUrl, clientHeaders, credentialsProvider, httpClient, executor); + baseUrl, + clientHeaders, + credentialsProvider, + httpClient, + executor, + connectTimeout, + requestTimeout, + logger); break; case VERSION_2025_03_26: this.delegate = new HttpMcpTransportV20250326( - baseUrl, clientHeaders, credentialsProvider, httpClient, executor); + baseUrl, + clientHeaders, + credentialsProvider, + httpClient, + executor, + connectTimeout, + requestTimeout, + logger); break; case VERSION_2024_11_05: this.delegate = new HttpMcpTransportV20241105( - baseUrl, clientHeaders, credentialsProvider, httpClient, executor); + baseUrl, + clientHeaders, + credentialsProvider, + httpClient, + executor, + connectTimeout, + requestTimeout, + logger); break; default: throw new IllegalArgumentException("Unsupported protocol version: " + version); } } - /** Internal constructor for testing purposes. */ + /** + * Internal constructor for testing purposes. + * + * @param baseUrl The base URL. + * @param httpClient The mock HttpClient. + */ public HttpMcpTransport(final String baseUrl, final HttpClient httpClient) { this(baseUrl, Map.of(), null, null, httpClient, null); } - /** Internal constructor for testing purposes. */ + /** + * Internal constructor for testing purposes. + * + * @param baseUrl The base URL. + * @param clientHeaders The client headers. + * @param httpClient The mock HttpClient. + */ public HttpMcpTransport( final String baseUrl, final Map clientHeaders, final HttpClient httpClient) { this(baseUrl, clientHeaders, null, null, httpClient, null); diff --git a/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransportV20241105.java b/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransportV20241105.java index f9a0576..9a7e39a 100644 --- a/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransportV20241105.java +++ b/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransportV20241105.java @@ -24,11 +24,23 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.time.Duration; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.logging.Logger; +/** HTTP transport implementation for protocol version 2024-11-05. */ public final class HttpMcpTransportV20241105 extends BaseMcpTransport { + /** + * Constructs a new HttpMcpTransportV20241105. + * + * @param baseUrl The base URL. + * @param clientHeaders The client headers. + * @param credentialsProvider The credentials provider. + * @param httpClient The HTTP client. + * @param executor The executor. + */ public HttpMcpTransportV20241105( final String baseUrl, final Map clientHeaders, @@ -44,6 +56,39 @@ public HttpMcpTransportV20241105( executor); } + /** + * Constructs a new HttpMcpTransportV20241105 with timeouts and logger. + * + * @param baseUrl The base URL. + * @param clientHeaders The client headers. + * @param credentialsProvider The credentials provider. + * @param httpClient The HTTP client. + * @param executor The executor. + * @param connectTimeout The connection timeout. + * @param requestTimeout The request timeout. + * @param logger The logger. + */ + public HttpMcpTransportV20241105( + final String baseUrl, + final Map clientHeaders, + final CredentialsProvider credentialsProvider, + final HttpClient httpClient, + final java.util.concurrent.Executor executor, + final Duration connectTimeout, + final Duration requestTimeout, + final Logger logger) { + super( + baseUrl, + clientHeaders, + credentialsProvider, + ProtocolVersion.VERSION_2024_11_05, + httpClient, + executor, + connectTimeout, + requestTimeout, + logger); + } + @Override protected CompletableFuture performInitialization( final String authHeader, final Map handshakeHeaders) { diff --git a/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransportV20250326.java b/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransportV20250326.java index d642076..1ebafd3 100644 --- a/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransportV20250326.java +++ b/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransportV20250326.java @@ -24,14 +24,26 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.time.Duration; import java.util.Map; import java.util.Optional; import java.util.concurrent.CompletableFuture; +import java.util.logging.Logger; +/** HTTP transport implementation for protocol version 2025-03-26. */ public final class HttpMcpTransportV20250326 extends BaseMcpTransport { private volatile String sessionId; + /** + * Constructs a new HttpMcpTransportV20250326. + * + * @param baseUrl The base URL. + * @param clientHeaders The client headers. + * @param credentialsProvider The credentials provider. + * @param httpClient The HTTP client. + * @param executor The executor. + */ public HttpMcpTransportV20250326( final String baseUrl, final Map clientHeaders, @@ -47,6 +59,39 @@ public HttpMcpTransportV20250326( executor); } + /** + * Constructs a new HttpMcpTransportV20250326 with timeouts and logger. + * + * @param baseUrl The base URL. + * @param clientHeaders The client headers. + * @param credentialsProvider The credentials provider. + * @param httpClient The HTTP client. + * @param executor The executor. + * @param connectTimeout The connection timeout. + * @param requestTimeout The request timeout. + * @param logger The logger. + */ + public HttpMcpTransportV20250326( + final String baseUrl, + final Map clientHeaders, + final CredentialsProvider credentialsProvider, + final HttpClient httpClient, + final java.util.concurrent.Executor executor, + final Duration connectTimeout, + final Duration requestTimeout, + final Logger logger) { + super( + baseUrl, + clientHeaders, + credentialsProvider, + ProtocolVersion.VERSION_2025_03_26, + httpClient, + executor, + connectTimeout, + requestTimeout, + logger); + } + @Override protected CompletableFuture performInitialization( final String authHeader, final Map handshakeHeaders) { diff --git a/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransportV20250618.java b/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransportV20250618.java index 8ac79c9..e444fbc 100644 --- a/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransportV20250618.java +++ b/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransportV20250618.java @@ -24,11 +24,23 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.time.Duration; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.logging.Logger; +/** HTTP transport implementation for protocol version 2025-06-18. */ public final class HttpMcpTransportV20250618 extends BaseMcpTransport { + /** + * Constructs a new HttpMcpTransportV20250618. + * + * @param baseUrl The base URL. + * @param clientHeaders The client headers. + * @param credentialsProvider The credentials provider. + * @param httpClient The HTTP client. + * @param executor The executor. + */ public HttpMcpTransportV20250618( final String baseUrl, final Map clientHeaders, @@ -44,6 +56,39 @@ public HttpMcpTransportV20250618( executor); } + /** + * Constructs a new HttpMcpTransportV20250618 with timeouts and logger. + * + * @param baseUrl The base URL. + * @param clientHeaders The client headers. + * @param credentialsProvider The credentials provider. + * @param httpClient The HTTP client. + * @param executor The executor. + * @param connectTimeout The connection timeout. + * @param requestTimeout The request timeout. + * @param logger The logger. + */ + public HttpMcpTransportV20250618( + final String baseUrl, + final Map clientHeaders, + final CredentialsProvider credentialsProvider, + final HttpClient httpClient, + final java.util.concurrent.Executor executor, + final Duration connectTimeout, + final Duration requestTimeout, + final Logger logger) { + super( + baseUrl, + clientHeaders, + credentialsProvider, + ProtocolVersion.VERSION_2025_06_18, + httpClient, + executor, + connectTimeout, + requestTimeout, + logger); + } + @Override protected CompletableFuture performInitialization( final String authHeader, final Map handshakeHeaders) { diff --git a/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransportV20251125.java b/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransportV20251125.java index 4ab7f37..40f9149 100644 --- a/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransportV20251125.java +++ b/src/main/java/com/google/cloud/mcp/transport/HttpMcpTransportV20251125.java @@ -24,11 +24,23 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.time.Duration; import java.util.Map; import java.util.concurrent.CompletableFuture; +import java.util.logging.Logger; +/** HTTP transport implementation for protocol version 2025-11-25. */ public final class HttpMcpTransportV20251125 extends BaseMcpTransport { + /** + * Constructs a new HttpMcpTransportV20251125. + * + * @param baseUrl The base URL. + * @param clientHeaders The client headers. + * @param credentialsProvider The credentials provider. + * @param httpClient The HTTP client. + * @param executor The executor. + */ public HttpMcpTransportV20251125( final String baseUrl, final Map clientHeaders, @@ -44,6 +56,39 @@ public HttpMcpTransportV20251125( executor); } + /** + * Constructs a new HttpMcpTransportV20251125 with timeouts and logger. + * + * @param baseUrl The base URL. + * @param clientHeaders The client headers. + * @param credentialsProvider The credentials provider. + * @param httpClient The HTTP client. + * @param executor The executor. + * @param connectTimeout The connection timeout. + * @param requestTimeout The request timeout. + * @param logger The logger. + */ + public HttpMcpTransportV20251125( + final String baseUrl, + final Map clientHeaders, + final CredentialsProvider credentialsProvider, + final HttpClient httpClient, + final java.util.concurrent.Executor executor, + final Duration connectTimeout, + final Duration requestTimeout, + final Logger logger) { + super( + baseUrl, + clientHeaders, + credentialsProvider, + ProtocolVersion.VERSION_2025_11_25, + httpClient, + executor, + connectTimeout, + requestTimeout, + logger); + } + @Override protected CompletableFuture performInitialization( final String authHeader, final Map handshakeHeaders) { diff --git a/src/main/java/com/google/cloud/mcp/transport/JsonRpc.java b/src/main/java/com/google/cloud/mcp/transport/JsonRpc.java index 9ec7719..4f85713 100644 --- a/src/main/java/com/google/cloud/mcp/transport/JsonRpc.java +++ b/src/main/java/com/google/cloud/mcp/transport/JsonRpc.java @@ -19,13 +19,30 @@ import java.util.Map; import java.util.UUID; -class JsonRpc { - static class Request { +/** Namespace for JSON-RPC 2.0 MC Protocol data structures. */ +public class JsonRpc { + private JsonRpc() {} + + /** Represents a JSON-RPC request. */ + public static class Request { + /** The JSON-RPC version. */ public String jsonrpc = "2.0"; + + /** The request ID. */ public String id; + + /** The method name. */ public String method; + + /** The parameters. */ public Object params; + /** + * Constructs a new Request. + * + * @param method The method name. + * @param params The parameters. + */ public Request(final String method, final Object params) { this.id = UUID.randomUUID().toString(); this.method = method; @@ -33,32 +50,66 @@ public Request(final String method, final Object params) { } } - static class Notification { + /** Represents a JSON-RPC notification. */ + public static class Notification { + /** The JSON-RPC version. */ public String jsonrpc = "2.0"; + + /** The method name. */ public String method; + + /** The parameters. */ public Object params; + /** + * Constructs a new Notification. + * + * @param method The method name. + * @param params The parameters. + */ public Notification(final String method, final Object params) { this.method = method; this.params = params; } } - static class CallToolParams { + /** Parameters for calling a tool. */ + public static class CallToolParams { + /** The name of the tool to call. */ public String name; + + /** The arguments for the tool call. */ public Map arguments; + /** + * Constructs a new CallToolParams. + * + * @param name The name of the tool. + * @param arguments The arguments. + */ public CallToolParams(final String name, final Map arguments) { this.name = name; this.arguments = arguments; } } - static class InitializeParams { + /** Parameters for initializing the connection. */ + public static class InitializeParams { + /** The protocol version. */ public String protocolVersion; + + /** The client capabilities. */ public Map capabilities; + + /** The client info. */ public Map clientInfo; + /** + * Constructs a new InitializeParams. + * + * @param version The protocol version. + * @param clientName The client name. + */ public InitializeParams(final String version, final String clientName) { this.protocolVersion = version; this.capabilities = Map.of(); diff --git a/src/test/java/com/google/cloud/mcp/McpToolboxSyncClientTest.java b/src/test/java/com/google/cloud/mcp/McpToolboxSyncClientTest.java new file mode 100644 index 0000000..992b926 --- /dev/null +++ b/src/test/java/com/google/cloud/mcp/McpToolboxSyncClientTest.java @@ -0,0 +1,364 @@ +/* + * 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.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.google.cloud.mcp.auth.AuthTokenGetter; +import com.google.cloud.mcp.client.HttpMcpToolboxSyncClient; +import com.google.cloud.mcp.exception.McpProtocolException; +import com.google.cloud.mcp.exception.McpToolboxException; +import com.google.cloud.mcp.exception.McpTransportException; +import com.google.cloud.mcp.exception.ToolExecutionException; +import com.google.cloud.mcp.tool.Tool; +import com.google.cloud.mcp.tool.ToolDefinition; +import com.google.cloud.mcp.tool.ToolResult; +import com.google.cloud.mcp.transport.HttpMcpTransport; +import java.net.http.HttpClient; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.logging.Logger; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; + +@Timeout(10) +class McpToolboxSyncClientTest { + + @Test + void testConstructorValidation() { + assertThrows(IllegalArgumentException.class, () -> new HttpMcpToolboxSyncClient(null)); + } + + @Test + void testBuilderConfigurationAndAdvancedTimeouts() { + HttpClient mockHttpClient = mock(HttpClient.class); + Logger mockLogger = Logger.getLogger("test"); + + McpToolboxClient asyncClient = + McpToolboxClient.builder() + .baseUrl("http://localhost:8080") + .apiKey("my-api-key") + .connectTimeout(Duration.ofSeconds(5)) + .requestTimeout(Duration.ofSeconds(3)) + .httpClient(mockHttpClient) + .logger(mockLogger) + .build(); + + assertNotNull(asyncClient); + + McpToolboxSyncClient syncClient = + McpToolboxClient.builder() + .baseUrl("http://localhost:8080") + .connectTimeout(Duration.ofSeconds(5)) + .requestTimeout(Duration.ofSeconds(3)) + .logger(mockLogger) + .buildSync(); + + assertNotNull(syncClient); + } + + @Test + void testSyncClientDelegationAllMethods() { + McpToolboxClient mockAsync = mock(McpToolboxClient.class); + McpToolboxSyncClient syncClient = new HttpMcpToolboxSyncClient(mockAsync); + + // 1. listTools() + Map expectedTools = Collections.emptyMap(); + when(mockAsync.listTools()).thenReturn(CompletableFuture.completedFuture(expectedTools)); + assertEquals(expectedTools, syncClient.listTools()); + verify(mockAsync).listTools(); + + // 2. loadToolset(String) + when(mockAsync.loadToolset("set1")) + .thenReturn(CompletableFuture.completedFuture(expectedTools)); + assertEquals(expectedTools, syncClient.loadToolset("set1")); + verify(mockAsync).loadToolset("set1"); + + // 3. loadToolset(String, Map, Map, boolean) + Map expectedLoadedTools = Collections.emptyMap(); + when(mockAsync.loadToolset("set1", Map.of(), Map.of(), true)) + .thenReturn(CompletableFuture.completedFuture(expectedLoadedTools)); + assertEquals(expectedLoadedTools, syncClient.loadToolset("set1", Map.of(), Map.of(), true)); + verify(mockAsync).loadToolset("set1", Map.of(), Map.of(), true); + + // 4. loadTool(String) + Tool mockTool = mock(Tool.class); + when(mockAsync.loadTool("tool1")).thenReturn(CompletableFuture.completedFuture(mockTool)); + assertEquals(mockTool, syncClient.loadTool("tool1")); + verify(mockAsync).loadTool("tool1"); + + // 5. loadTool(String, Map) + when(mockAsync.loadTool("tool1", Map.of())) + .thenReturn(CompletableFuture.completedFuture(mockTool)); + assertEquals(mockTool, syncClient.loadTool("tool1", Map.of())); + verify(mockAsync).loadTool("tool1", Map.of()); + + // 6. invokeTool(String, Map) + ToolResult mockResult = new ToolResult(Collections.emptyList(), false); + when(mockAsync.invokeTool("tool1", Map.of())) + .thenReturn(CompletableFuture.completedFuture(mockResult)); + assertEquals(mockResult, syncClient.invokeTool("tool1", Map.of())); + verify(mockAsync).invokeTool("tool1", Map.of()); + + // 7. invokeTool(String, Map, Map) + when(mockAsync.invokeTool("tool1", Map.of(), Map.of())) + .thenReturn(CompletableFuture.completedFuture(mockResult)); + assertEquals(mockResult, syncClient.invokeTool("tool1", Map.of(), Map.of())); + verify(mockAsync).invokeTool("tool1", Map.of(), Map.of()); + } + + @Test + void testExceptionTranslationDifferentTypes() { + McpToolboxClient mockAsync = mock(McpToolboxClient.class); + McpToolboxSyncClient syncClient = new HttpMcpToolboxSyncClient(mockAsync); + + // 1. McpToolboxException -> rethrown directly + CompletableFuture> f1 = new CompletableFuture<>(); + f1.completeExceptionally(new McpToolboxException("toolbox-error")); + when(mockAsync.listTools()).thenReturn(f1); + McpToolboxException ex1 = assertThrows(McpToolboxException.class, () -> syncClient.listTools()); + assertEquals("toolbox-error", ex1.getMessage()); + + // 2. IllegalArgumentException -> rethrown directly + CompletableFuture> f2 = new CompletableFuture<>(); + f2.completeExceptionally(new IllegalArgumentException("illegal-arg")); + when(mockAsync.listTools()).thenReturn(f2); + IllegalArgumentException ex2 = + assertThrows(IllegalArgumentException.class, () -> syncClient.listTools()); + assertEquals("illegal-arg", ex2.getMessage()); + + // 3. Other checked Exception -> wrapped in McpToolboxException + CompletableFuture> f3 = new CompletableFuture<>(); + f3.completeExceptionally(new Exception("generic-error")); + when(mockAsync.listTools()).thenReturn(f3); + McpToolboxException ex3 = assertThrows(McpToolboxException.class, () -> syncClient.listTools()); + assertEquals("generic-error", ex3.getMessage()); + + // 4. Null cause -> wrapped in McpToolboxException + CompletableFuture> f4 = new CompletableFuture<>(); + f4.completeExceptionally(new java.util.concurrent.CompletionException(null)); + when(mockAsync.listTools()).thenReturn(f4); + McpToolboxException ex4 = assertThrows(McpToolboxException.class, () -> syncClient.listTools()); + assertNotNull(ex4); + } + + @Test + void testToolExecuteSyncExceptionTranslation() { + McpToolboxClient mockAsync = mock(McpToolboxClient.class); + ToolDefinition def = + new ToolDefinition("test description", Collections.emptyList(), Collections.emptyList()); + Tool tool = new Tool("test-tool", def, mockAsync); + + // 1. Tool execution McpToolboxException -> rethrown + CompletableFuture f1 = new CompletableFuture<>(); + f1.completeExceptionally(new McpToolboxException("execution-failed")); + when(mockAsync.invokeTool(any(), any(), any())).thenReturn(f1); + McpToolboxException ex1 = + assertThrows(McpToolboxException.class, () -> tool.executeSync(Collections.emptyMap())); + assertEquals("execution-failed", ex1.getMessage()); + + // 2. Tool execution IllegalArgumentException -> rethrown + CompletableFuture f2 = new CompletableFuture<>(); + f2.completeExceptionally(new IllegalArgumentException("invalid-arg")); + when(mockAsync.invokeTool(any(), any(), any())).thenReturn(f2); + IllegalArgumentException ex2 = + assertThrows( + IllegalArgumentException.class, () -> tool.executeSync(Collections.emptyMap())); + assertEquals("invalid-arg", ex2.getMessage()); + + // 3. Tool execution other Exception -> wrapped in McpToolboxException + CompletableFuture f3 = new CompletableFuture<>(); + f3.completeExceptionally(new RuntimeException("general-fail")); + when(mockAsync.invokeTool(any(), any(), any())).thenReturn(f3); + McpToolboxException ex3 = + assertThrows(McpToolboxException.class, () -> tool.executeSync(Collections.emptyMap())); + assertEquals("general-fail", ex3.getMessage()); + + // 4. Tool execution Null cause -> wrapped in McpToolboxException + CompletableFuture f4 = new CompletableFuture<>(); + f4.completeExceptionally(new java.util.concurrent.CompletionException(null)); + when(mockAsync.invokeTool(any(), any(), any())).thenReturn(f4); + McpToolboxException ex4 = + assertThrows(McpToolboxException.class, () -> tool.executeSync(Collections.emptyMap())); + assertNotNull(ex4); + } + + @Test + void testToolDeepCopyListSupport() { + McpToolboxClient mockAsync = mock(McpToolboxClient.class); + // Set up a parameter with a default value that is a List + List defaultList = List.of("element1", List.of("subelement")); + ToolDefinition.Parameter param = + new ToolDefinition.Parameter( + "listParam", "array", false, "list param", Collections.emptyList(), defaultList); + ToolDefinition def = + new ToolDefinition("test description", List.of(param), Collections.emptyList()); + Tool tool = new Tool("test-tool", def, mockAsync); + + // Call invokeTool and verify the list is deep-copied and passed + ToolResult expectedResult = new ToolResult(Collections.emptyList(), false); + when(mockAsync.invokeTool(any(), any(), any())) + .thenReturn(CompletableFuture.completedFuture(expectedResult)); + + // Execute with empty args so the default value (list) is used and deep-copied + ToolResult actualResult = tool.executeSync(Collections.emptyMap()); + assertEquals(expectedResult, actualResult); + } + + @Test + void testSyncClientDelegationExceptions() { + McpToolboxClient mockAsync = mock(McpToolboxClient.class); + McpToolboxSyncClient syncClient = new HttpMcpToolboxSyncClient(mockAsync); + + CompletableFuture failedFuture = + CompletableFuture.failedFuture(new McpToolboxException("error")); + + // 1. loadToolset(String) exception + when(mockAsync.loadToolset("set1")).thenAnswer(inv -> failedFuture); + assertThrows(McpToolboxException.class, () -> syncClient.loadToolset("set1")); + + // 2. loadToolset(String, Map, Map, boolean) exception + when(mockAsync.loadToolset("set1", Map.of(), Map.of(), true)).thenAnswer(inv -> failedFuture); + assertThrows( + McpToolboxException.class, () -> syncClient.loadToolset("set1", Map.of(), Map.of(), true)); + + // 3. loadTool(String) exception + when(mockAsync.loadTool("tool1")).thenAnswer(inv -> failedFuture); + assertThrows(McpToolboxException.class, () -> syncClient.loadTool("tool1")); + + // 4. loadTool(String, Map) exception + when(mockAsync.loadTool("tool1", Map.of())).thenAnswer(inv -> failedFuture); + assertThrows(McpToolboxException.class, () -> syncClient.loadTool("tool1", Map.of())); + + // 5. invokeTool(String, Map) exception + when(mockAsync.invokeTool("tool1", Map.of())).thenAnswer(inv -> failedFuture); + assertThrows(McpToolboxException.class, () -> syncClient.invokeTool("tool1", Map.of())); + + // 6. invokeTool(String, Map, Map) exception + when(mockAsync.invokeTool("tool1", Map.of(), Map.of())).thenAnswer(inv -> failedFuture); + assertThrows( + McpToolboxException.class, () -> syncClient.invokeTool("tool1", Map.of(), Map.of())); + } + + @Test + void testExceptionsAndInterfaceCoverage() { + // 1. Instantiate the exception classes + assertNotNull(new McpProtocolException("protocol error")); + assertNotNull(new McpProtocolException("protocol error", new Exception())); + assertNotNull(new ToolExecutionException("execution error")); + assertNotNull(new ToolExecutionException("execution error", new Exception())); + assertNotNull(new McpTransportException("transport error")); + assertNotNull(new McpTransportException("transport error", new Exception())); + + // 2. Interface default method call + McpToolboxSyncClient syncClient = + new McpToolboxSyncClient() { + @Override + public Map listTools() { + return Collections.emptyMap(); + } + + @Override + public Map loadToolset(String toolsetName) { + return Collections.emptyMap(); + } + + @Override + public Map loadToolset( + String toolsetName, + Map> paramBinds, + Map> authBinds, + boolean strict) { + return Collections.emptyMap(); + } + + @Override + public Tool loadTool(String toolName) { + return null; + } + + @Override + public Tool loadTool(String toolName, Map authTokenGetters) { + return null; + } + + @Override + public ToolResult invokeTool(String toolName, Map arguments) { + return null; + } + + @Override + public ToolResult invokeTool( + String toolName, Map arguments, Map extraHeaders) { + return null; + } + }; + assertEquals(Collections.emptyMap(), syncClient.loadToolset()); + } + + @Test + @SuppressWarnings("unchecked") + void testRequestTimeoutPropagation() throws Exception { + HttpClient mockHttpClient = mock(HttpClient.class); + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(200); + when(mockInitResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"2025-11-25\"}}"); + + HttpResponse mockInitializedResponse = mock(HttpResponse.class); + when(mockInitializedResponse.statusCode()).thenReturn(200); + when(mockInitializedResponse.body()).thenReturn(""); + + HttpResponse mockListResponse = mock(HttpResponse.class); + when(mockListResponse.statusCode()).thenReturn(200); + when(mockListResponse.body()) + .thenReturn("{\"jsonrpc\":\"2.0\",\"id\":\"2\",\"result\":{\"tools\":[]}}"); + + when(mockHttpClient.sendAsync( + any(java.net.http.HttpRequest.class), + any(java.net.http.HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockInitResponse)) + .thenReturn(CompletableFuture.completedFuture(mockInitializedResponse)) + .thenReturn(CompletableFuture.completedFuture(mockListResponse)); + + HttpMcpTransport transport = + new HttpMcpTransport( + "http://localhost:8080", + Map.of(), + null, + ProtocolVersion.VERSION_2025_11_25, + mockHttpClient, + null, + Duration.ofSeconds(1), + Duration.ofSeconds(1), + null); + + transport.listTools("", Collections.emptyMap()).get(); + assertNotNull(transport); + } +} diff --git a/src/test/java/com/google/cloud/mcp/client/McpToolboxClientBuilderTest.java b/src/test/java/com/google/cloud/mcp/client/McpToolboxClientBuilderTest.java index b5b1168..fcc2082 100644 --- a/src/test/java/com/google/cloud/mcp/client/McpToolboxClientBuilderTest.java +++ b/src/test/java/com/google/cloud/mcp/client/McpToolboxClientBuilderTest.java @@ -27,6 +27,7 @@ import com.google.cloud.mcp.ProtocolVersion; import com.google.cloud.mcp.auth.CredentialsProvider; import com.google.cloud.mcp.exception.McpException; +import com.google.cloud.mcp.exception.McpTransportException; import com.google.cloud.mcp.tool.ToolPostProcessor; import com.google.cloud.mcp.tool.ToolPreProcessor; import com.google.cloud.mcp.transport.Transport; @@ -199,4 +200,26 @@ void testProtocolVersionFromString() { assertNull(ProtocolVersion.fromString("invalid-version")); assertEquals(ProtocolVersion.VERSION_2025_11_25, ProtocolVersion.fromString("2025-11-25")); } + + @Test + void testMcpTransportExceptionConstructors() { + McpTransportException ex1 = new McpTransportException("msg1"); + assertEquals("msg1", ex1.getMessage()); + assertEquals(-1, ex1.getStatusCode()); + + McpTransportException ex2 = new McpTransportException("msg2", 404); + assertEquals("msg2", ex2.getMessage()); + assertEquals(404, ex2.getStatusCode()); + + RuntimeException cause = new RuntimeException("root"); + McpTransportException ex3 = new McpTransportException("msg3", cause); + assertEquals("msg3", ex3.getMessage()); + assertSame(cause, ex3.getCause()); + assertEquals(-1, ex3.getStatusCode()); + + McpTransportException ex4 = new McpTransportException("msg4", 500, cause); + assertEquals("msg4", ex4.getMessage()); + assertSame(cause, ex4.getCause()); + assertEquals(500, ex4.getStatusCode()); + } } diff --git a/src/test/java/com/google/cloud/mcp/client/McpToolboxClientImplJsonRpcTest.java b/src/test/java/com/google/cloud/mcp/client/McpToolboxClientImplJsonRpcTest.java index 7721f3d..94b95f8 100644 --- a/src/test/java/com/google/cloud/mcp/client/McpToolboxClientImplJsonRpcTest.java +++ b/src/test/java/com/google/cloud/mcp/client/McpToolboxClientImplJsonRpcTest.java @@ -30,6 +30,7 @@ import com.google.cloud.mcp.tool.ToolDefinition; import com.google.cloud.mcp.tool.ToolResult; import com.google.cloud.mcp.transport.HttpMcpTransport; +import com.google.cloud.mcp.transport.JsonRpc; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; @@ -490,6 +491,15 @@ void testListTools_withMissingInputSchemaOrProperties() throws Exception { assertTrue(toolDef.parameters().isEmpty()); } + @Test + void testJsonRpcInstantiation() throws Exception { + // Instantiate private JsonRpc namespace to cover its constructor + java.lang.reflect.Constructor constructor = JsonRpc.class.getDeclaredConstructor(); + constructor.setAccessible(true); + JsonRpc rpc = constructor.newInstance(); + assertNotNull(rpc); + } + @Test void testListTools_withMetaNodeEdgeCases() throws Exception { HttpResponse initResponse = mock(HttpResponse.class); diff --git a/src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportTest.java b/src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportTest.java index 0953558..7921c15 100644 --- a/src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportTest.java +++ b/src/test/java/com/google/cloud/mcp/transport/HttpMcpTransportTest.java @@ -33,6 +33,7 @@ import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.time.Duration; import java.util.Collections; import java.util.List; import java.util.Map; @@ -280,7 +281,6 @@ void testConstructor_WithCustomExecutorConfiguresHttpClient() throws Exception { } } - @Test @SuppressWarnings("unchecked") void testInitialize_ServerReturnsErrorJsonRpcResponse() throws Exception { HttpResponse mockInitResponse = mock(HttpResponse.class); @@ -476,9 +476,47 @@ void testListTools_ParsesComplexToolsCorrectly() throws Exception { } @Test - void testJsonRpcInstantiation() { - // Instantiate package-private JsonRpc namespace to cover its default constructor - JsonRpc rpc = new JsonRpc(); - assertNotNull(rpc); + @SuppressWarnings("unchecked") + void testInvokeTool_WithRequestTimeout() throws Exception { + HttpMcpTransport transportWithTimeout = + new HttpMcpTransport( + "https://test-mcp-service.com", + Map.of(), + null, + ProtocolVersion.VERSION_2025_11_25, + mockClient, + null, + Duration.ofSeconds(5), + Duration.ofSeconds(3), + null); + + HttpResponse mockInitResponse = mock(HttpResponse.class); + when(mockInitResponse.statusCode()).thenReturn(200); + when(mockInitResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"1\",\"result\":{\"protocolVersion\":\"2025-11-25\"}}"); + + HttpResponse mockInitializedResponse = mock(HttpResponse.class); + when(mockInitializedResponse.statusCode()).thenReturn(200); + when(mockInitializedResponse.body()).thenReturn(""); + + HttpResponse mockInvokeResponse = mock(HttpResponse.class); + when(mockInvokeResponse.statusCode()).thenReturn(200); + when(mockInvokeResponse.body()) + .thenReturn( + "{\"jsonrpc\":\"2.0\",\"id\":\"3\",\"result\":{\"content\":[{\"type\":\"text\",\"text\":\"success\"}]}}"); + + when(mockClient.sendAsync(any(HttpRequest.class), any(HttpResponse.BodyHandler.class))) + .thenReturn(CompletableFuture.completedFuture(mockInitResponse)) + .thenReturn(CompletableFuture.completedFuture(mockInitializedResponse)) + .thenReturn(CompletableFuture.completedFuture(mockInvokeResponse)); + + TransportResponse response = + transportWithTimeout + .invokeTool("test-tool", Map.of("param1", "value1"), Collections.emptyMap()) + .get(); + + assertNotNull(response); + assertEquals(200, response.getStatusCode()); } }