diff --git a/demo-applications/spring-boot-postgres/README.md b/demo-applications/spring-boot-postgres/README.md new file mode 100644 index 0000000..4882024 --- /dev/null +++ b/demo-applications/spring-boot-postgres/README.md @@ -0,0 +1,156 @@ +# Spring Boot MCP Toolbox PostgreSQL Example 🚀 + +This sample application demonstrates how to build a modern **Spring Boot 3** microservice integrated with the **MCP Toolbox Java SDK** (`com.google.cloud.mcp:mcp-toolbox-sdk-java`). + +The application connects to an official [MCP Toolbox](https://github.com/googleapis/mcp-toolbox) server running in Docker, configured with a custom declarative **`tools.yaml`** that defines domain-specific database tools (`get-all-products`, `get-product-by-id`, `get-products-by-category`, `add-product`, `delete-product-by-id`, `list_tables`, `get-table-schema`) backed by a live **PostgreSQL** instance. + +--- + +## Architecture Overview + +``` +┌─────────────────────────────────┐ +│ Spring Boot 3 Application │ +│ (Port 8080) │ +│ - ProductController (REST API) │ +│ - ProductCatalogService │ +│ - McpToolboxClient (Java SDK) │ +└────────────────┬────────────────┘ + │ HTTP (JSON-RPC 2.0 /mcp) + ▼ +┌─────────────────────────────────┐ +│ MCP Toolbox Server (Docker) │ +│ (Port 5005:5000) │ +│ --config /tools.yaml │ +│ (Custom declarative tools) │ +└────────────────┬────────────────┘ + │ TCP (5432) + ▼ +┌─────────────────────────────────┐ +│ PostgreSQL 15 (Docker) │ +│ (Port 5433:5432, DB: mcpdb) │ +│ - Table: products │ +└─────────────────────────────────┘ +``` + +--- + +## Custom Declarative `tools.yaml` + +Rather than exposing arbitrary raw SQL execution, the application defines domain-specific tools declaratively in [`tools.yaml`](./tools.yaml): + +| Tool Name | Parameters | Description | +| :--- | :--- | :--- | +| `get-all-products` | None | Retrieves all products ordered by ID. | +| `get-product-by-id` | `id` (integer) | Retrieves a single product by its ID. | +| `get-products-by-category` | `category` (string) | Retrieves products filtered by category. | +| `add-product` | `name` (string), `category` (string, optional), `price` (float), `stock` (integer) | Inserts a product using parameterized queries (`$1, $2, $3, $4`) and returns the created record via `RETURNING`. | +| `delete-product-by-id` | `id` (integer) | Deletes a product by ID. | +| `list_tables` | None | Lists public tables from `information_schema.tables`. | +| `get-table-schema` | `table_name` (string) | Introspects column types and nullability for a table. | + +### Why Declarative Tools? +1. **Parameterized Security**: Toolbox binds arguments via PostgreSQL prepared statement parameters (`$1`, `$2`), eliminating SQL injection risks. +2. **Schema & Validation**: Input types, required parameters, and descriptions are enforced at the MCP layer before hitting the database. +3. **Domain Abstraction**: LLMs and microservices interact with clean business tools rather than raw SQL commands. + +## Maven Dependency + +Add the MCP Toolbox Java SDK dependency to your `pom.xml`: + +```xml + + com.google.cloud.mcp + mcp-toolbox-sdk-java + 1.0.0 + +``` + +--- + +## Prerequisites + +- **Java 17+** (JDK 17, 21, or 26) +- **Maven 3.9+** +- **Docker** container engine + +--- + +## Quickstart + +### 1. Start the Docker Containers + +Run the automated startup script to launch PostgreSQL and MCP Toolbox: + +```bash +./scripts/start-containers.sh +``` + +This script: +1. Spawns a PostgreSQL container (`mcp-postgres`) on port `5433`. +2. Seeds a sample `products` table with initial records. +3. Spawns the MCP Toolbox server (`mcp-toolbox`) linked to PostgreSQL on port `5005`. +4. Waits until the server responds to tool discovery. + +### 2. Run the Test Suites + +Execute standalone unit tests (offline, hermetic, no Docker containers required): + +```bash +mvn clean test -Dnet.bytebuddy.experimental=true +``` + +Execute end-to-end integration tests (requires Docker containers running from Step 1): + +```bash +mvn clean verify -Dnet.bytebuddy.experimental=true +``` + +### 3. Run the Spring Boot Application + +```bash +mvn spring-boot:run +``` + +The application will start on `http://localhost:8080`. + +--- + +## REST Endpoints + +### 1. List Available MCP Tools +```bash +curl -s http://localhost:8080/api/tools | jq . +``` + +### 2. Get All Products +```bash +curl -s http://localhost:8080/api/products | jq . +``` + +### 3. Create a New Product +```bash +curl -s -X POST http://localhost:8080/api/products \ + -H "Content-Type: application/json" \ + -d '{ + "name": "Ultra HD Monitor 34-inch", + "category": "Electronics", + "price": 649.99, + "stock": 30 + }' +``` + +### 4. Inspect Table Schema +```bash +curl -s http://localhost:8080/api/schema/products | jq . +``` + +--- + +## Teardown + +To shut down and remove the test containers: + +```bash +./scripts/stop-containers.sh +``` diff --git a/demo-applications/spring-boot-postgres/pom.xml b/demo-applications/spring-boot-postgres/pom.xml new file mode 100644 index 0000000..438df9c --- /dev/null +++ b/demo-applications/spring-boot-postgres/pom.xml @@ -0,0 +1,109 @@ + + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.1.3 + + + + com.google.cloud.mcp.example + spring-boot-postgres-example + 1.0.0 + Spring Boot MCP Toolbox PostgreSQL Example + Demonstration of using the Java MCP Toolbox SDK within a Spring Boot 3 application connected to a containerized PostgreSQL instance. + + + 17 + 17 + 17 + UTF-8 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + com.google.cloud.mcp + mcp-toolbox-sdk-java + 1.0.0 + + + + + com.fasterxml.jackson.core + jackson-databind + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + org.apache.maven.plugins + maven-surefire-plugin + + -Dnet.bytebuddy.experimental=true + + **/*Test.java + + + **/*IT.java + + + + + org.apache.maven.plugins + maven-failsafe-plugin + + -Dnet.bytebuddy.experimental=true + + **/*IT.java + + + + + + integration-test + verify + + + + + + + diff --git a/demo-applications/spring-boot-postgres/scripts/start-containers.sh b/demo-applications/spring-boot-postgres/scripts/start-containers.sh new file mode 100755 index 0000000..cce193d --- /dev/null +++ b/demo-applications/spring-boot-postgres/scripts/start-containers.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# 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. + +set -euo pipefail + +NETWORK_NAME="${NETWORK_NAME:-mcp-network}" + +echo "==> Cleaning up previous test containers and network if running..." +docker rm -f mcp-toolbox mcp-postgres 2>/dev/null || true +docker network rm "${NETWORK_NAME}" 2>/dev/null || true +docker network create "${NETWORK_NAME}" + +POSTGRES_USER="${POSTGRES_USER:-mcpuser}" +POSTGRES_PASSWORD="${POSTGRES_PASSWORD:-mcppass}" +POSTGRES_DATABASE="${POSTGRES_DATABASE:-mcpdb}" + +echo "==> Starting PostgreSQL container (port 5433:5432)..." +docker run -d --name mcp-postgres --network "${NETWORK_NAME}" --network-alias postgres -p 5433:5432 \ + -e POSTGRES_USER="${POSTGRES_USER}" \ + -e POSTGRES_PASSWORD="${POSTGRES_PASSWORD}" \ + -e POSTGRES_DB="${POSTGRES_DATABASE}" \ + postgres:15-alpine + +echo "==> Waiting for PostgreSQL to be ready..." +until docker exec mcp-postgres pg_isready -U "${POSTGRES_USER}" -d "${POSTGRES_DATABASE}"; do + sleep 1 +done + +echo "==> Initializing schema and seed data in PostgreSQL from schema.sql..." +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +docker exec -i mcp-postgres psql -U "${POSTGRES_USER}" -d "${POSTGRES_DATABASE}" < "${SCRIPT_DIR}/../src/main/resources/schema.sql" + +echo "==> Starting MCP Toolbox container with custom tools.yaml (port 5005:5000)..." +docker run -d --name mcp-toolbox --network "${NETWORK_NAME}" -p 5005:5000 \ + -e POSTGRES_HOST=postgres \ + -e POSTGRES_PORT=5432 \ + -e POSTGRES_DATABASE="${POSTGRES_DATABASE}" \ + -e POSTGRES_USER="${POSTGRES_USER}" \ + -e POSTGRES_PASSWORD="${POSTGRES_PASSWORD}" \ + -v "${SCRIPT_DIR}/../tools.yaml:/tools.yaml:ro" \ + us-central1-docker.pkg.dev/database-toolbox/toolbox/toolbox:latest \ + --config /tools.yaml --address 0.0.0.0 --port 5000 + +echo "==> Waiting for MCP Toolbox to be ready on http://localhost:5005/mcp..." +until curl -s -X POST http://localhost:5005/mcp -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | grep -q "get-all-products"; do + sleep 1 +done + +echo "==> MCP Toolbox Server is healthy and accepting requests on http://localhost:5005/mcp." diff --git a/demo-applications/spring-boot-postgres/scripts/stop-containers.sh b/demo-applications/spring-boot-postgres/scripts/stop-containers.sh new file mode 100755 index 0000000..51563ac --- /dev/null +++ b/demo-applications/spring-boot-postgres/scripts/stop-containers.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# 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. + +NETWORK_NAME="${NETWORK_NAME:-mcp-network}" +docker rm -f mcp-toolbox mcp-postgres 2>/dev/null || true +docker network rm "${NETWORK_NAME}" 2>/dev/null || true +echo "==> Test containers and network stopped and removed." diff --git a/demo-applications/spring-boot-postgres/src/main/java/com/google/cloud/mcp/example/SpringBootPostgresApplication.java b/demo-applications/spring-boot-postgres/src/main/java/com/google/cloud/mcp/example/SpringBootPostgresApplication.java new file mode 100644 index 0000000..c2e7a12 --- /dev/null +++ b/demo-applications/spring-boot-postgres/src/main/java/com/google/cloud/mcp/example/SpringBootPostgresApplication.java @@ -0,0 +1,29 @@ +/* + * 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 + * + * https://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.example; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** Entry point for the Spring Boot MCP Toolbox PostgreSQL Example Application. */ +@SpringBootApplication +public class SpringBootPostgresApplication { + + public static void main(String[] args) { + SpringApplication.run(SpringBootPostgresApplication.class, args); + } +} diff --git a/demo-applications/spring-boot-postgres/src/main/java/com/google/cloud/mcp/example/config/McpToolboxConfig.java b/demo-applications/spring-boot-postgres/src/main/java/com/google/cloud/mcp/example/config/McpToolboxConfig.java new file mode 100644 index 0000000..0fcd9d2 --- /dev/null +++ b/demo-applications/spring-boot-postgres/src/main/java/com/google/cloud/mcp/example/config/McpToolboxConfig.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 + * + * https://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.example.config; + +import com.google.cloud.mcp.McpToolboxClient; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +/** Configuration class that registers the {@link McpToolboxClient} as a Spring bean. */ +@Configuration +public class McpToolboxConfig { + + private static final Logger logger = LoggerFactory.getLogger(McpToolboxConfig.class); + + @Value("${mcp.toolbox.url:http://localhost:5005/mcp}") + private String toolboxUrl; + + @Bean + public McpToolboxClient mcpToolboxClient() { + logger.info("Initializing McpToolboxClient configured with baseUrl: {}", toolboxUrl); + return McpToolboxClient.builder().baseUrl(toolboxUrl).build(); + } +} diff --git a/demo-applications/spring-boot-postgres/src/main/java/com/google/cloud/mcp/example/controller/ProductController.java b/demo-applications/spring-boot-postgres/src/main/java/com/google/cloud/mcp/example/controller/ProductController.java new file mode 100644 index 0000000..324e838 --- /dev/null +++ b/demo-applications/spring-boot-postgres/src/main/java/com/google/cloud/mcp/example/controller/ProductController.java @@ -0,0 +1,174 @@ +/* + * 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 + * + * https://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.example.controller; + +import com.google.cloud.mcp.example.model.Product; +import com.google.cloud.mcp.example.service.ProductCatalogService; +import java.net.URI; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** REST controller exposing product catalog and MCP tool inspection APIs. */ +@RestController +@RequestMapping("/api") +public class ProductController { + + private final ProductCatalogService catalogService; + + public ProductController(ProductCatalogService catalogService) { + this.catalogService = catalogService; + } + + /** + * Returns list of tools available on the MCP Toolbox server. + * + * @return List of tool names. + */ + @GetMapping("/tools") + public CompletableFuture>> getAvailableTools() { + return catalogService.listAvailableTools().thenApply(tools -> ResponseEntity.ok(tools)); + } + + /** + * Returns all products in the database. + * + * @return List of products. + */ + @GetMapping("/products") + public CompletableFuture>> getAllProducts() { + return catalogService.getAllProducts().thenApply(products -> ResponseEntity.ok(products)); + } + + /** + * Returns a product by its ID. + * + * @param id The product ID. + * @return The product or HTTP 404 Not Found. + */ + @GetMapping("/products/{id}") + public CompletableFuture> getProductById(@PathVariable Long id) { + if (id == null || id <= 0) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("Product ID must be positive")); + } + return catalogService + .getProductById(id) + .thenApply( + product -> + product != null ? ResponseEntity.ok(product) : ResponseEntity.notFound().build()); + } + + /** + * Returns products belonging to a specified category. + * + * @param category Category name. + * @return List of matching products. + */ + @GetMapping("/products/category/{category}") + public CompletableFuture>> getProductsByCategory( + @PathVariable String category) { + if (category == null || category.trim().isEmpty()) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("Product category cannot be null or empty")); + } + return catalogService.getProductsByCategory(category.trim()).thenApply(ResponseEntity::ok); + } + + /** + * Creates a new product. + * + * @param product Product details. + * @return HTTP 201 Created with Location header and persisted product. + */ + @PostMapping("/products") + public CompletableFuture> createProduct(@RequestBody Product product) { + if (product == null) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("Product payload cannot be null")); + } + if (product.name() == null || product.name().trim().isEmpty()) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("Product name cannot be null or empty")); + } + if (product.price() == null) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("Product price cannot be null")); + } + if (product.stock() == null) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("Product stock cannot be null")); + } + return catalogService + .addProduct(product.name(), product.category(), product.price(), product.stock()) + .thenApply( + created -> + ResponseEntity.created(URI.create("/api/products/" + created.id())).body(created)); + } + + /** + * Deletes a product by its ID. + * + * @param id The product ID. + * @return HTTP 204 No Content if deleted, or HTTP 404 Not Found. + */ + @DeleteMapping("/products/{id}") + public CompletableFuture> deleteProduct(@PathVariable Long id) { + if (id == null || id <= 0) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("Product ID must be positive")); + } + return catalogService + .deleteProductById(id) + .thenApply( + deleted -> + deleted ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build()); + } + + /** + * Returns database table schema. + * + * @param tableName Name of the table. + * @return Schema JSON string. + */ + @GetMapping("/schema/{tableName}") + public CompletableFuture> getTableSchema(@PathVariable String tableName) { + return catalogService.getTableSchema(tableName).thenApply(schema -> ResponseEntity.ok(schema)); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException ex) { + String message = ex.getMessage() != null ? ex.getMessage() : "Invalid request argument"; + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(Map.of("error", message)); + } + + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity> handleIllegalState(IllegalStateException ex) { + String message = ex.getMessage() != null ? ex.getMessage() : "Internal server state error"; + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(Map.of("error", message)); + } +} diff --git a/demo-applications/spring-boot-postgres/src/main/java/com/google/cloud/mcp/example/model/Product.java b/demo-applications/spring-boot-postgres/src/main/java/com/google/cloud/mcp/example/model/Product.java new file mode 100644 index 0000000..d7c96ca --- /dev/null +++ b/demo-applications/spring-boot-postgres/src/main/java/com/google/cloud/mcp/example/model/Product.java @@ -0,0 +1,37 @@ +/* + * 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 + * + * https://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.example.model; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * Data transfer record representing a product in the catalog. + * + * @param id Unique identifier of the product. + * @param name Name of the product. + * @param category Catalog category. + * @param price Retail price. + * @param stock Quantity in stock. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record Product( + @JsonProperty("id") Long id, + @JsonProperty("name") String name, + @JsonProperty("category") String category, + @JsonProperty("price") Double price, + @JsonProperty("stock") Integer stock) {} diff --git a/demo-applications/spring-boot-postgres/src/main/java/com/google/cloud/mcp/example/service/ProductCatalogService.java b/demo-applications/spring-boot-postgres/src/main/java/com/google/cloud/mcp/example/service/ProductCatalogService.java new file mode 100644 index 0000000..331b699 --- /dev/null +++ b/demo-applications/spring-boot-postgres/src/main/java/com/google/cloud/mcp/example/service/ProductCatalogService.java @@ -0,0 +1,293 @@ +/* + * 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 + * + * https://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.example.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.cloud.mcp.McpToolboxClient; +import com.google.cloud.mcp.example.model.Product; +import com.google.cloud.mcp.tool.ToolResult; +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.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +/** Service orchestrating product catalog operations against PostgreSQL via the MCP Toolbox SDK. */ +@Service +public class ProductCatalogService { + + private static final Logger logger = LoggerFactory.getLogger(ProductCatalogService.class); + + private final McpToolboxClient client; + private final ObjectMapper objectMapper; + + public ProductCatalogService(McpToolboxClient client, ObjectMapper objectMapper) { + this.client = client; + this.objectMapper = objectMapper; + } + + /** + * Discovers and lists all tools exposed by the MCP Toolbox server. + * + * @return CompletableFuture containing list of tool names. + */ + public CompletableFuture> listAvailableTools() { + return client + .listTools() + .orTimeout(10, TimeUnit.SECONDS) + .thenApplyAsync( + tools -> { + List names = tools.keySet().stream().sorted().toList(); + logger.debug("Discovered {} tools: {}", names.size(), names); + return names; + }); + } + + /** + * Retrieves all products from the PostgreSQL database using the 'get-all-products' tool. + * + * @return CompletableFuture containing list of {@link Product} objects. + */ + public CompletableFuture> getAllProducts() { + logger.debug("Invoking 'get-all-products' tool via MCP"); + return client + .invokeTool("get-all-products", Collections.emptyMap()) + .orTimeout(10, TimeUnit.SECONDS) + .thenApplyAsync(this::parseProductsResult); + } + + /** + * Retrieves a single product by its unique identifier using the 'get-product-by-id' tool. + * + * @param id The product ID (must be positive). + * @return CompletableFuture containing {@link Product} or null if not found. + */ + public CompletableFuture getProductById(Long id) { + if (id == null || id <= 0) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("Product ID must be positive")); + } + logger.debug("Invoking 'get-product-by-id' tool via MCP for id: {}", id); + return client + .invokeTool("get-product-by-id", Map.of("id", id)) + .orTimeout(10, TimeUnit.SECONDS) + .thenApplyAsync(this::parseProductsResult) + .thenApply(products -> products.isEmpty() ? null : products.get(0)); + } + + /** + * Retrieves products in a category using the 'get-products-by-category' tool. + * + * @param category The category name (required, non-blank). + * @return CompletableFuture containing list of {@link Product} objects. + */ + public CompletableFuture> getProductsByCategory(String category) { + if (category == null || category.trim().isEmpty()) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("Product category cannot be null or empty")); + } + String trimmedCategory = category.trim(); + logger.debug("Invoking 'get-products-by-category' tool via MCP for: {}", trimmedCategory); + return client + .invokeTool("get-products-by-category", Map.of("category", trimmedCategory)) + .orTimeout(10, TimeUnit.SECONDS) + .thenApplyAsync(this::parseProductsResult); + } + + /** + * Inserts a new product into the database using the 'add-product' declarative tool. + * + * @param name Name of the product (required, non-blank, max 100 characters). + * @param category Category of the product (optional, max 50 characters). + * @param price Price of the product (must be non-negative, finite number). + * @param stock Stock quantity (must be non-negative). + * @return CompletableFuture containing the newly persisted {@link Product}. + */ + public CompletableFuture addProduct( + String name, String category, double price, int stock) { + if (name == null || name.trim().isEmpty()) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("Product name cannot be null or empty")); + } + String trimmedName = name.trim(); + if (trimmedName.length() > 100) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("Product name cannot exceed 100 characters")); + } + + String trimmedCategory = category != null ? category.trim() : null; + if (trimmedCategory != null && trimmedCategory.length() > 50) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("Product category cannot exceed 50 characters")); + } + + if (Double.isNaN(price) || Double.isInfinite(price) || price < 0.0) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("Product price must be a valid finite non-negative number")); + } + if (stock < 0) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("Product stock must be non-negative")); + } + + Map arguments = new HashMap<>(); + arguments.put("name", trimmedName); + if (trimmedCategory != null && !trimmedCategory.isEmpty()) { + arguments.put("category", trimmedCategory); + } + arguments.put("price", price); + arguments.put("stock", stock); + + logger.debug("Invoking 'add-product' tool via MCP for: {}", trimmedName); + + return client + .invokeTool("add-product", arguments) + .orTimeout(10, TimeUnit.SECONDS) + .thenApplyAsync(this::parseProductsResult) + .thenApply( + products -> { + if (products.isEmpty()) { + throw new IllegalStateException("Insert succeeded but no product record returned"); + } + Product created = products.get(0); + logger.info( + "Product successfully persisted with id {}: {}", created.id(), trimmedName); + return created; + }); + } + + /** + * Deletes a product from the database using the 'delete-product-by-id' tool. + * + * @param id The product ID (must be positive). + * @return CompletableFuture containing true if deleted, false otherwise. + */ + public CompletableFuture deleteProductById(Long id) { + if (id == null || id <= 0) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("Product ID must be positive")); + } + logger.debug("Invoking 'delete-product-by-id' tool via MCP for id: {}", id); + return client + .invokeTool("delete-product-by-id", Map.of("id", id)) + .orTimeout(10, TimeUnit.SECONDS) + .thenApply( + result -> { + if (result.isError()) { + String errorMsg = extractErrorMessage(result); + logger.error("Failed to delete product {}: {}", id, errorMsg); + throw new IllegalStateException("Tool execution failed: " + errorMsg); + } + if (result.content() != null && !result.content().isEmpty()) { + String text = result.content().get(0).text(); + if (text == null || text.isBlank()) { + return false; + } + try { + JsonNode rootNode = objectMapper.readTree(text); + if (rootNode.isArray()) { + return !rootNode.isEmpty() + && rootNode.get(0).has("id") + && rootNode.get(0).get("id").asLong() == id; + } else if (rootNode.isObject()) { + return rootNode.has("id") && rootNode.get("id").asLong() == id; + } + } catch (JsonProcessingException e) { + logger.warn("Could not parse delete response as JSON: {}", text); + } + } + return false; + }); + } + + /** + * Introspects table existence and metadata using declarative schema tools. + * + * @param tableName Name of the table to introspect. + * @return CompletableFuture containing table introspection metadata string. + */ + public CompletableFuture getTableSchema(String tableName) { + logger.debug("Inspecting table metadata for: {}", tableName); + boolean hasTable = tableName != null && !tableName.trim().isEmpty(); + String toolName = hasTable ? "get-table-schema" : "list_tables"; + Map args = + hasTable ? Map.of("table_name", tableName.trim()) : Collections.emptyMap(); + + return client + .invokeTool(toolName, args) + .orTimeout(10, TimeUnit.SECONDS) + .thenApply( + result -> { + if (result.isError()) { + throw new IllegalStateException( + "Schema inspection failed: " + extractErrorMessage(result)); + } + if (result.content() != null && !result.content().isEmpty()) { + return result.content().get(0).text(); + } + return "{}"; + }); + } + + private List parseProductsResult(ToolResult result) { + if (result.isError()) { + String errorMsg = extractErrorMessage(result); + logger.error("Tool query returned error: {}", errorMsg); + throw new IllegalStateException("Query failed: " + errorMsg); + } + + if (result.content() == null || result.content().isEmpty()) { + return Collections.emptyList(); + } + + List products = new ArrayList<>(); + for (var content : result.content()) { + String rawJson = content.text(); + if (rawJson != null && !rawJson.trim().isEmpty()) { + String trimmed = rawJson.trim(); + try { + if (trimmed.startsWith("[")) { + Product[] parsedArray = objectMapper.readValue(trimmed, Product[].class); + Collections.addAll(products, parsedArray); + } else { + Product product = objectMapper.readValue(trimmed, Product.class); + products.add(product); + } + } catch (JsonProcessingException e) { + logger.error("Could not deserialize content as Product: {}", trimmed, e); + throw new IllegalStateException( + "Failed to deserialize product catalog payload: " + e.getMessage(), e); + } + } + } + return products; + } + + private String extractErrorMessage(ToolResult result) { + if (result.content() != null && !result.content().isEmpty()) { + return result.content().get(0).text(); + } + return "Unknown error"; + } +} diff --git a/demo-applications/spring-boot-postgres/src/main/resources/application.properties b/demo-applications/spring-boot-postgres/src/main/resources/application.properties new file mode 100644 index 0000000..69751bf --- /dev/null +++ b/demo-applications/spring-boot-postgres/src/main/resources/application.properties @@ -0,0 +1,9 @@ +# Server configuration +server.port=8080 + +# MCP Toolbox Server Endpoint +mcp.toolbox.url=http://localhost:5005/mcp + +# Logging configuration +logging.level.com.google.cloud.mcp=DEBUG +logging.level.org.springframework.web=INFO diff --git a/demo-applications/spring-boot-postgres/src/main/resources/schema.sql b/demo-applications/spring-boot-postgres/src/main/resources/schema.sql new file mode 100644 index 0000000..6dfa2b0 --- /dev/null +++ b/demo-applications/spring-boot-postgres/src/main/resources/schema.sql @@ -0,0 +1,17 @@ +-- Table definition for products catalog +CREATE TABLE IF NOT EXISTS products ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + category VARCHAR(50), + price NUMERIC(10,2) NOT NULL CHECK (price >= 0), + stock INT NOT NULL CHECK (stock >= 0) +); + +CREATE INDEX IF NOT EXISTS idx_products_category ON products(category); + +-- Seed data for initial catalog +INSERT INTO products (name, category, price, stock) VALUES +('Quantum Laptop', 'Electronics', 1299.99, 45), +('Ergonomic Mechanical Keyboard', 'Accessories', 149.50, 120), +('Noise Cancelling Headphones', 'Audio', 299.00, 75) +ON CONFLICT DO NOTHING; diff --git a/demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/SpringBootPostgresApplicationIT.java b/demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/SpringBootPostgresApplicationIT.java new file mode 100644 index 0000000..7e2197e --- /dev/null +++ b/demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/SpringBootPostgresApplicationIT.java @@ -0,0 +1,263 @@ +/* + * 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 + * + * https://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.example; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.cloud.mcp.McpToolboxClient; +import com.google.cloud.mcp.example.model.Product; +import com.google.cloud.mcp.example.service.ProductCatalogService; +import com.google.cloud.mcp.tool.ToolDefinition; +import com.google.cloud.mcp.tool.ToolResult; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.client.TestRestTemplate; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +/** + * End-to-end hermetic integration tests verifying Spring Boot integration with the MCP Toolbox Java + * SDK, connecting to a containerized MCP Toolbox server and PostgreSQL instance. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@Timeout(value = 30, unit = TimeUnit.SECONDS) +class SpringBootPostgresApplicationIT { + + @Autowired private McpToolboxClient mcpToolboxClient; + + @Autowired private ProductCatalogService catalogService; + + @Autowired private TestRestTemplate restTemplate; + + @Test + @DisplayName("Context loads and McpToolboxClient bean is created") + void testContextLoadsAndClientBeanConfigured() { + assertNotNull(mcpToolboxClient, "McpToolboxClient bean should be present in context"); + assertNotNull(catalogService, "ProductCatalogService bean should be present in context"); + } + + @Test + @DisplayName("SDK client discovers PostgreSQL tools from MCP Toolbox") + void testToolDiscovery_ContainsPostgresTools() { + Map tools = mcpToolboxClient.listTools().join(); + assertNotNull(tools, "Discovered tools map should not be null"); + assertThat(tools).isNotEmpty(); + assertThat(tools.keySet()) + .contains( + "get-all-products", + "get-product-by-id", + "add-product", + "delete-product-by-id", + "list_tables", + "get-table-schema"); + } + + @Test + @DisplayName("Service retrieves seeded products via get-all-products tool") + void testQueryProducts_ReturnsSeededItems() { + List products = catalogService.getAllProducts().join(); + assertNotNull(products, "Products list should not be null"); + assertThat(products).hasSizeGreaterThanOrEqualTo(3); + + List names = products.stream().map(Product::name).toList(); + assertThat(names) + .contains("Quantum Laptop", "Ergonomic Mechanical Keyboard", "Noise Cancelling Headphones"); + } + + @Test + @DisplayName("Service persists new product via add-product tool and queries it back") + void testInsertProduct_PersistsAndCanBeQueried() { + String uniqueItemName = "Gaming Mouse " + UUID.randomUUID().toString().substring(0, 8); + Product created = catalogService.addProduct(uniqueItemName, "Gaming", 79.99, 50).join(); + assertNotNull(created); + assertThat(created.id()).isNotNull(); + + Product queried = catalogService.getProductById(created.id()).join(); + assertNotNull(queried); + assertThat(queried.name()).isEqualTo(uniqueItemName); + assertThat(queried.category()).isEqualTo("Gaming"); + } + + @Test + @DisplayName("Service introspects table schema via get-table-schema tool") + void testListTables_DiscoversProductsTable() throws Exception { + String schemaOutput = catalogService.getTableSchema("products").join(); + assertNotNull(schemaOutput, "Schema output should not be null"); + JsonNode root = new ObjectMapper().readTree(schemaOutput); + assertThat(root.isContainerNode()).isTrue(); + if (root.isArray()) { + assertThat(root.size()).isGreaterThan(0); + assertThat(root.get(0).path("table_name").asText()).isEqualTo("products"); + } else { + assertThat(root.path("table_name").asText()).isEqualTo("products"); + } + } + + @Test + @DisplayName("SDK client handles invalid tool execution gracefully") + void testExecuteTool_InvalidArguments_HandlesErrorGracefully() { + ToolResult result = + mcpToolboxClient + .invokeTool("get-product-by-id", Map.of("id", "invalid_non_numeric_id")) + .join(); + + assertNotNull(result, "ToolResult should not be null"); + assertTrue(result.isError(), "Result should report error flag for invalid argument type"); + assertThat(result.content()).isNotEmpty(); + } + + @Test + @DisplayName("REST Controller GET /api/tools returns available tools") + void testRestController_GetTools() { + ResponseEntity response = restTemplate.getForEntity("/api/tools", String[].class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertNotNull(response.getBody()); + assertThat(response.getBody()).contains("get-all-products", "add-product"); + } + + @Test + @DisplayName("REST Controller GET /api/products returns product list") + void testRestController_GetProducts() { + ResponseEntity response = + restTemplate.getForEntity("/api/products", Product[].class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertNotNull(response.getBody()); + assertThat(response.getBody()).isNotEmpty(); + } + + @Test + @DisplayName("REST Controller POST /api/products creates product with Location header and body") + void testRestController_PostProduct() { + String uniqueItemName = "Earbuds " + UUID.randomUUID().toString().substring(0, 8); + Product newProduct = new Product(null, uniqueItemName, "Audio", 199.99, 85); + + ResponseEntity response = + restTemplate.postForEntity("/api/products", newProduct, Product.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + assertNotNull(response.getBody()); + assertThat(response.getBody().id()).isNotNull(); + assertThat(response.getBody().name()).isEqualTo(uniqueItemName); + + assertNotNull(response.getHeaders().getLocation()); + assertThat(response.getHeaders().getLocation().getPath()) + .isEqualTo("/api/products/" + response.getBody().id()); + } + + @Test + @DisplayName("REST Controller POST /api/products rejects invalid product with 400 Bad Request") + void testRestController_PostProduct_ValidationFailure() { + Product invalidProduct = new Product(null, "", "Electronics", -10.0, -5); + HttpEntity request = new HttpEntity<>(invalidProduct); + + ResponseEntity> response = + restTemplate.exchange( + "/api/products", + HttpMethod.POST, + request, + new ParameterizedTypeReference>() {}); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertNotNull(response.getBody()); + assertThat(response.getBody()).containsKey("error"); + } + + @Test + @DisplayName("REST Controller POST /api/products rejects null price with 400 Bad Request") + void testRestController_PostProduct_NullPrice_ValidationFailure() { + Product nullPriceProduct = new Product(null, "No Price Item", "Electronics", null, 10); + HttpEntity request = new HttpEntity<>(nullPriceProduct); + + ResponseEntity> response = + restTemplate.exchange( + "/api/products", + HttpMethod.POST, + request, + new ParameterizedTypeReference>() {}); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.BAD_REQUEST); + assertNotNull(response.getBody()); + assertThat(response.getBody()).containsKey("error"); + } + + @Test + @DisplayName("Service handles null category by storing SQL NULL") + void testInsertProduct_NullCategory_PersistsAsNull() { + String itemName = "Uncategorized " + UUID.randomUUID().toString().substring(0, 8); + Product created = catalogService.addProduct(itemName, null, 19.99, 10).join(); + assertNotNull(created); + assertThat(created.category()).isNull(); + } + + @Test + @DisplayName("REST Controller GET /api/products/{id} returns single product") + void testRestController_GetProductById() { + ResponseEntity response = restTemplate.getForEntity("/api/products/1", Product.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertNotNull(response.getBody()); + assertThat(response.getBody().id()).isEqualTo(1L); + assertThat(response.getBody().name()).isEqualTo("Quantum Laptop"); + } + + @Test + @DisplayName("REST Controller GET /api/products/category/{category} returns filtered products") + void testRestController_GetProductsByCategory() { + ResponseEntity response = + restTemplate.getForEntity("/api/products/category/Electronics", Product[].class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); + assertNotNull(response.getBody()); + assertThat(response.getBody()).isNotEmpty(); + for (Product p : response.getBody()) { + assertThat(p.category()).isEqualTo("Electronics"); + } + } + + @Test + @DisplayName("REST Controller DELETE /api/products/{id} deletes product returning 204") + void testRestController_DeleteProduct() { + String tempName = "DeleteMe " + UUID.randomUUID().toString().substring(0, 8); + Product created = catalogService.addProduct(tempName, "Temp", 15.0, 5).join(); + assertNotNull(created); + Long id = created.id(); + + ResponseEntity deleteResponse = + restTemplate.exchange("/api/products/" + id, HttpMethod.DELETE, null, Void.class); + assertThat(deleteResponse.getStatusCode()).isEqualTo(HttpStatus.NO_CONTENT); + + ResponseEntity getResponse = + restTemplate.getForEntity("/api/products/" + id, Product.class); + assertThat(getResponse.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND); + } +} diff --git a/demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/service/ProductCatalogServiceTest.java b/demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/service/ProductCatalogServiceTest.java new file mode 100644 index 0000000..d398e65 --- /dev/null +++ b/demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/service/ProductCatalogServiceTest.java @@ -0,0 +1,375 @@ +/* + * 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 + * + * https://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.example.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +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 com.fasterxml.jackson.databind.ObjectMapper; +import com.google.cloud.mcp.McpToolboxClient; +import com.google.cloud.mcp.example.model.Product; +import com.google.cloud.mcp.tool.ToolDefinition; +import com.google.cloud.mcp.tool.ToolResult; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionException; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.mockito.ArgumentCaptor; + +@Timeout(value = 10, unit = TimeUnit.SECONDS) +class ProductCatalogServiceTest { + + private McpToolboxClient mockClient; + private ObjectMapper objectMapper; + private ProductCatalogService service; + + @BeforeEach + void setUp() { + mockClient = mock(McpToolboxClient.class); + objectMapper = new ObjectMapper(); + service = new ProductCatalogService(mockClient, objectMapper); + } + + @Test + @DisplayName("listAvailableTools sorts discovered tool names alphabetically") + void testListAvailableTools_SortsAlphabetically() { + ToolDefinition toolA = mock(ToolDefinition.class); + ToolDefinition toolB = mock(ToolDefinition.class); + ToolDefinition toolC = mock(ToolDefinition.class); + + when(mockClient.listTools()) + .thenReturn( + CompletableFuture.completedFuture( + Map.of("list_tables", toolC, "execute_sql", toolA, "database_overview", toolB))); + + List tools = service.listAvailableTools().join(); + + assertThat(tools).containsExactly("database_overview", "execute_sql", "list_tables"); + } + + @Test + @DisplayName("getAllProducts parses individual row objects in ToolResult content") + void testGetAllProducts_SingleObjects() { + ToolResult result = + new ToolResult( + List.of( + new ToolResult.Content( + "text", + "{\"id\":1,\"name\":\"Mouse\",\"category\":\"Accessories\",\"price\":29.99,\"stock\":100}"), + new ToolResult.Content( + "text", + "{\"id\":2,\"name\":\"Keyboard\",\"category\":null,\"price\":89.99,\"stock\":50}")), + false); + + when(mockClient.invokeTool(eq("get-all-products"), any())) + .thenReturn(CompletableFuture.completedFuture(result)); + + List products = service.getAllProducts().join(); + + assertThat(products).hasSize(2); + assertEquals("Mouse", products.get(0).name()); + assertEquals("Keyboard", products.get(1).name()); + assertNull(products.get(1).category()); + } + + @Test + @DisplayName("getAllProducts parses single content containing a JSON array") + void testGetAllProducts_JsonArray() { + String jsonArray = + "[{\"id\":10,\"name\":\"Monitor\",\"category\":\"Displays\",\"price\":299.99,\"stock\":15},{\"id\":11,\"name\":\"Desk" + + " Lamp\",\"category\":\"Lighting\",\"price\":39.99,\"stock\":40}]"; + ToolResult result = new ToolResult(List.of(new ToolResult.Content("text", jsonArray)), false); + + when(mockClient.invokeTool(eq("get-all-products"), any())) + .thenReturn(CompletableFuture.completedFuture(result)); + + List products = service.getAllProducts().join(); + + assertThat(products).hasSize(2); + assertEquals("Monitor", products.get(0).name()); + assertEquals("Desk Lamp", products.get(1).name()); + } + + @Test + @DisplayName("getProductById retrieves single product via get-product-by-id tool") + void testGetProductById_Success() { + ToolResult result = + new ToolResult( + List.of( + new ToolResult.Content( + "text", + "{\"id\":1,\"name\":\"Quantum" + + " Laptop\",\"category\":\"Electronics\",\"price\":1299.99,\"stock\":45}")), + false); + + when(mockClient.invokeTool(eq("get-product-by-id"), any())) + .thenReturn(CompletableFuture.completedFuture(result)); + + Product product = service.getProductById(1L).join(); + + assertThat(product).isNotNull(); + assertEquals(1L, product.id()); + assertEquals("Quantum Laptop", product.name()); + } + + @Test + @DisplayName("getProductById rejects non-positive or null ID with IllegalArgumentException") + void testGetProductById_InvalidId() { + assertValidationFailure(service.getProductById(null), "ID must be positive"); + assertValidationFailure(service.getProductById(0L), "ID must be positive"); + assertValidationFailure(service.getProductById(-1L), "ID must be positive"); + } + + @Test + @DisplayName("getProductsByCategory filters products via get-products-by-category tool") + void testGetProductsByCategory_Success() { + ToolResult result = + new ToolResult( + List.of( + new ToolResult.Content( + "text", + "{\"id\":1,\"name\":\"Quantum" + + " Laptop\",\"category\":\"Electronics\",\"price\":1299.99,\"stock\":45}")), + false); + + when(mockClient.invokeTool(eq("get-products-by-category"), any())) + .thenReturn(CompletableFuture.completedFuture(result)); + + List products = service.getProductsByCategory("Electronics").join(); + + assertThat(products).hasSize(1); + assertEquals("Electronics", products.get(0).category()); + } + + @Test + @DisplayName("addProduct passes typed arguments map to add-product tool and returns product") + void testAddProduct_Valid_PassesArgumentsWithCategory() { + ToolResult successResult = + new ToolResult( + List.of( + new ToolResult.Content( + "text", + "{\"id\":10,\"name\":\"O'Reilly" + + " Book\",\"category\":\"Books\",\"price\":49.99,\"stock\":10}")), + false); + when(mockClient.invokeTool(eq("add-product"), any())) + .thenReturn(CompletableFuture.completedFuture(successResult)); + + Product created = service.addProduct("O'Reilly Book", "Books", 49.99, 10).join(); + + assertThat(created).isNotNull(); + assertEquals(10, created.id()); + assertEquals("O'Reilly Book", created.name()); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(mockClient).invokeTool(eq("add-product"), captor.capture()); + + Map args = captor.getValue(); + assertEquals("O'Reilly Book", args.get("name")); + assertEquals("Books", args.get("category")); + assertEquals(49.99, args.get("price")); + assertEquals(10, args.get("stock")); + } + + @Test + @DisplayName("addProduct omits category key when null or empty and returns product") + void testAddProduct_Valid_OmitsNullCategory() { + ToolResult successResult = + new ToolResult( + List.of( + new ToolResult.Content( + "text", + "{\"id\":11,\"name\":\"Generic" + + " Item\",\"category\":null,\"price\":9.99,\"stock\":5}")), + false); + when(mockClient.invokeTool(eq("add-product"), any())) + .thenReturn(CompletableFuture.completedFuture(successResult)); + + Product created = service.addProduct("Generic Item", null, 9.99, 5).join(); + + assertThat(created).isNotNull(); + assertEquals(11, created.id()); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(mockClient).invokeTool(eq("add-product"), captor.capture()); + + Map args = captor.getValue(); + assertEquals("Generic Item", args.get("name")); + assertThat(args).doesNotContainKey("category"); + assertEquals(9.99, args.get("price")); + assertEquals(5, args.get("stock")); + } + + @Test + @DisplayName("addProduct rejects invalid inputs with IllegalArgumentException") + void testAddProduct_ValidationFailures() { + // Null name + assertValidationFailure(service.addProduct(null, "Cat", 10.0, 1), "name cannot be null"); + + // Blank name + assertValidationFailure(service.addProduct(" ", "Cat", 10.0, 1), "name cannot be null"); + + // Name too long (> 100) + assertValidationFailure( + service.addProduct("A".repeat(101), "Cat", 10.0, 1), "cannot exceed 100"); + + // Category too long (> 50) + assertValidationFailure( + service.addProduct("Valid", "B".repeat(51), 10.0, 1), "cannot exceed 50"); + + // Negative price + assertValidationFailure(service.addProduct("Valid", "Cat", -1.0, 1), "price must be a valid"); + + // NaN price + assertValidationFailure( + service.addProduct("Valid", "Cat", Double.NaN, 1), "price must be a valid"); + + // Infinite price + assertValidationFailure( + service.addProduct("Valid", "Cat", Double.POSITIVE_INFINITY, 1), "price must be a valid"); + + // Negative stock + assertValidationFailure( + service.addProduct("Valid", "Cat", 10.0, -1), "stock must be non-negative"); + } + + private void assertValidationFailure(CompletableFuture future, String expectedMessage) { + CompletionException ex = assertThrows(CompletionException.class, future::join); + assertThat(ex.getCause()).isInstanceOf(IllegalArgumentException.class); + if (expectedMessage != null) { + assertThat(ex.getCause().getMessage()).contains(expectedMessage); + } + } + + @Test + @DisplayName("getTableSchema passes 'table_name' parameter to get-table-schema tool") + void testGetTableSchema_PassesTableNameParam() throws Exception { + ToolResult schemaResult = + new ToolResult( + List.of(new ToolResult.Content("text", "{\"table_name\":\"products\"}")), false); + when(mockClient.invokeTool(eq("get-table-schema"), any())) + .thenReturn(CompletableFuture.completedFuture(schemaResult)); + + String schema = service.getTableSchema("products").join(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(mockClient).invokeTool(eq("get-table-schema"), captor.capture()); + + assertEquals("products", captor.getValue().get("table_name")); + com.fasterxml.jackson.databind.JsonNode root = new ObjectMapper().readTree(schema); + assertEquals("products", root.path("table_name").asText()); + } + + @Test + @DisplayName("Malformed JSON payload throws IllegalStateException") + void testMalformedJsonPayload_ThrowsIllegalStateException() { + ToolResult badJsonResult = + new ToolResult(List.of(new ToolResult.Content("text", "{malformed_json: true")), false); + when(mockClient.invokeTool(eq("get-all-products"), any())) + .thenReturn(CompletableFuture.completedFuture(badJsonResult)); + + CompletionException ex = + assertThrows(CompletionException.class, () -> service.getAllProducts().join()); + assertThat(ex.getCause()).isInstanceOf(IllegalStateException.class); + assertThat(ex.getCause().getMessage()) + .contains("Failed to deserialize product catalog payload"); + } + + @Test + @DisplayName("Tool execution error throws IllegalStateException") + void testToolExecutionError_ThrowsIllegalStateException() { + ToolResult errorResult = + new ToolResult(List.of(new ToolResult.Content("text", "Tool invocation failed")), true); + when(mockClient.invokeTool(eq("get-all-products"), any())) + .thenReturn(CompletableFuture.completedFuture(errorResult)); + + CompletionException ex = + assertThrows(CompletionException.class, () -> service.getAllProducts().join()); + assertThat(ex.getCause()).isInstanceOf(IllegalStateException.class); + assertThat(ex.getCause().getMessage()).contains("Tool invocation failed"); + } + + @Test + @DisplayName("deleteProductById deletes product via delete-product-by-id tool") + void testDeleteProductById_Success() { + ToolResult deleteResult = + new ToolResult(List.of(new ToolResult.Content("text", "{\"id\":1}")), false); + when(mockClient.invokeTool(eq("delete-product-by-id"), any())) + .thenReturn(CompletableFuture.completedFuture(deleteResult)); + + boolean deleted = service.deleteProductById(1L).join(); + assertThat(deleted).isTrue(); + } + + @Test + @DisplayName("deleteProductById deletes product when result is a JSON array") + void testDeleteProductById_ArraySuccess() { + ToolResult deleteResult = + new ToolResult(List.of(new ToolResult.Content("text", "[{\"id\":1}]")), false); + when(mockClient.invokeTool(eq("delete-product-by-id"), any())) + .thenReturn(CompletableFuture.completedFuture(deleteResult)); + + boolean deleted = service.deleteProductById(1L).join(); + assertThat(deleted).isTrue(); + } + + @Test + @DisplayName("deleteProductById returns false when ID not found") + void testDeleteProductById_NotFound() { + ToolResult emptyResult = new ToolResult(List.of(new ToolResult.Content("text", "[]")), false); + when(mockClient.invokeTool(eq("delete-product-by-id"), any())) + .thenReturn(CompletableFuture.completedFuture(emptyResult)); + + boolean deleted = service.deleteProductById(999L).join(); + assertThat(deleted).isFalse(); + } + + @Test + @DisplayName("deleteProductById returns false when response payload is malformed JSON") + void testDeleteProductById_MalformedJson() { + ToolResult malformedResult = + new ToolResult(List.of(new ToolResult.Content("text", "{malformed_id:")), false); + when(mockClient.invokeTool(eq("delete-product-by-id"), any())) + .thenReturn(CompletableFuture.completedFuture(malformedResult)); + + boolean deleted = service.deleteProductById(1L).join(); + assertThat(deleted).isFalse(); + } + + @Test + @DisplayName("deleteProductById rejects non-positive or null ID with IllegalArgumentException") + void testDeleteProductById_InvalidId() { + assertValidationFailure(service.deleteProductById(null), "ID must be positive"); + assertValidationFailure(service.deleteProductById(0L), "ID must be positive"); + assertValidationFailure(service.deleteProductById(-1L), "ID must be positive"); + } +} diff --git a/demo-applications/spring-boot-postgres/tools.yaml b/demo-applications/spring-boot-postgres/tools.yaml new file mode 100644 index 0000000..d363ee0 --- /dev/null +++ b/demo-applications/spring-boot-postgres/tools.yaml @@ -0,0 +1,113 @@ +# 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 +# +# https://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. + +sources: + postgres-db: + kind: postgres + host: ${POSTGRES_HOST} + port: 5432 + database: ${POSTGRES_DATABASE} + user: ${POSTGRES_USER} + password: ${POSTGRES_PASSWORD} + +tools: + get-all-products: + kind: postgres-sql + source: postgres-db + description: Retrieves all products from the catalog ordered by ID. + statement: | + SELECT id, name, category, price, stock FROM products ORDER BY id; + + get-product-by-id: + kind: postgres-sql + source: postgres-db + description: Retrieves a single product by its unique identifier. + parameters: + - name: id + type: integer + description: The ID of the product. + required: true + statement: | + SELECT id, name, category, price, stock FROM products WHERE id = $1; + + get-products-by-category: + kind: postgres-sql + source: postgres-db + description: Retrieves products belonging to a specified category. + parameters: + - name: category + type: string + description: Category name to filter by. + required: true + statement: | + SELECT id, name, category, price, stock FROM products WHERE category = $1 ORDER BY id; + + add-product: + kind: postgres-sql + source: postgres-db + description: Inserts a new product into the database and returns the created record. + parameters: + - name: name + type: string + description: Name of the product. + required: true + - name: category + type: string + description: Optional category of the product. + required: false + - name: price + type: float + description: Retail price of the product. + required: true + - name: stock + type: integer + description: Stock quantity available. + required: true + statement: | + INSERT INTO products (name, category, price, stock) VALUES ($1, $2, $3, $4) + RETURNING id, name, category, price, stock; + + delete-product-by-id: + kind: postgres-sql + source: postgres-db + description: Deletes a product from the catalog by its ID and returns the deleted ID. + parameters: + - name: id + type: integer + description: The ID of the product to delete. + required: true + statement: | + DELETE FROM products WHERE id = $1 RETURNING id; + + list_tables: + kind: postgres-sql + source: postgres-db + description: Lists table schemas from the PostgreSQL database public schema. + statement: | + SELECT table_name, table_type FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name; + + get-table-schema: + kind: postgres-sql + source: postgres-db + description: Retrieves column schema information for a specific table. + parameters: + - name: table_name + type: string + description: Name of the table to introspect. + required: true + statement: | + SELECT table_name, column_name, data_type, is_nullable + FROM information_schema.columns + WHERE table_schema = 'public' AND table_name = $1 + ORDER BY ordinal_position; diff --git a/release-please-config.json b/release-please-config.json index c7f2182..537b33c 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -32,7 +32,9 @@ "example/README.md", "example/pom.xml", "demo-applications/cymbal-transit/README.md", - "demo-applications/cymbal-transit/pom.xml" + "demo-applications/cymbal-transit/pom.xml", + "demo-applications/spring-boot-postgres/README.md", + "demo-applications/spring-boot-postgres/pom.xml" ] } },