From 78f54c9ce4a4ac498a3fdd46b30560dee12356bc Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Thu, 10 Sep 2026 12:18:16 +0530 Subject: [PATCH 1/6] feat: add Spring Boot MCP Toolbox PostgreSQL example application --- .../spring-boot-postgres/README.md | 116 +++++++++ .../spring-boot-postgres/pom.xml | 86 +++++++ .../scripts/start-containers.sh | 53 ++++ .../scripts/stop-containers.sh | 17 ++ .../SpringBootPostgresApplication.java | 29 +++ .../mcp/example/config/McpToolboxConfig.java | 40 +++ .../example/controller/ProductController.java | 104 ++++++++ .../cloud/mcp/example/model/Product.java | 37 +++ .../service/ProductCatalogService.java | 210 ++++++++++++++++ .../src/main/resources/application.properties | 9 + .../src/main/resources/schema.sql | 15 ++ .../SpringBootPostgresApplicationTests.java | 201 ++++++++++++++++ .../service/ProductCatalogServiceTest.java | 227 ++++++++++++++++++ 13 files changed, 1144 insertions(+) create mode 100644 demo-applications/spring-boot-postgres/README.md create mode 100644 demo-applications/spring-boot-postgres/pom.xml create mode 100755 demo-applications/spring-boot-postgres/scripts/start-containers.sh create mode 100755 demo-applications/spring-boot-postgres/scripts/stop-containers.sh create mode 100644 demo-applications/spring-boot-postgres/src/main/java/com/google/cloud/mcp/example/SpringBootPostgresApplication.java create mode 100644 demo-applications/spring-boot-postgres/src/main/java/com/google/cloud/mcp/example/config/McpToolboxConfig.java create mode 100644 demo-applications/spring-boot-postgres/src/main/java/com/google/cloud/mcp/example/controller/ProductController.java create mode 100644 demo-applications/spring-boot-postgres/src/main/java/com/google/cloud/mcp/example/model/Product.java create mode 100644 demo-applications/spring-boot-postgres/src/main/java/com/google/cloud/mcp/example/service/ProductCatalogService.java create mode 100644 demo-applications/spring-boot-postgres/src/main/resources/application.properties create mode 100644 demo-applications/spring-boot-postgres/src/main/resources/schema.sql create mode 100644 demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/SpringBootPostgresApplicationTests.java create mode 100644 demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/service/ProductCatalogServiceTest.java diff --git a/demo-applications/spring-boot-postgres/README.md b/demo-applications/spring-boot-postgres/README.md new file mode 100644 index 0000000..143c3de --- /dev/null +++ b/demo-applications/spring-boot-postgres/README.md @@ -0,0 +1,116 @@ +# 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, which exposes prebuilt database tools (`execute_sql`, `list_tables`, `database_overview`, etc.) 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) │ +│ --prebuilt postgres │ +└────────────────┬────────────────┘ + │ TCP (5432) + ▼ +┌─────────────────────────────────┐ +│ PostgreSQL 15 (Docker) │ +│ (Port 5433:5432, DB: mcpdb) │ +│ - Table: products │ +└─────────────────────────────────┘ +``` + +--- + +## 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 Suite + +Execute the integration test suite: + +```bash +mvn clean test -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..6ac5a5a --- /dev/null +++ b/demo-applications/spring-boot-postgres/pom.xml @@ -0,0 +1,86 @@ + + + + 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 + 1.0.0 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + com.google.cloud.mcp + mcp-toolbox-sdk-java + ${mcp-toolbox-sdk.version} + + + + + 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 + + + + + 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..fefbcf6 --- /dev/null +++ b/demo-applications/spring-boot-postgres/scripts/start-containers.sh @@ -0,0 +1,53 @@ +#!/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 + +echo "==> Cleaning up previous test containers if running..." +docker rm -f mcp-toolbox mcp-postgres 2>/dev/null || true + +echo "==> Starting PostgreSQL container (port 5433:5432)..." +docker run -d --name mcp-postgres -p 5433:5432 \ + -e POSTGRES_USER=mcpuser \ + -e POSTGRES_PASSWORD=mcppass \ + -e POSTGRES_DB=mcpdb \ + postgres:15-alpine + +echo "==> Waiting for PostgreSQL to be ready..." +until docker exec mcp-postgres pg_isready -U mcpuser -d mcpdb; 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 mcpuser -d mcpdb < "${SCRIPT_DIR}/../src/main/resources/schema.sql" + +echo "==> Starting MCP Toolbox container (port 5005:5000)..." +docker run -d --name mcp-toolbox --link mcp-postgres:postgres -p 5005:5000 \ + -e POSTGRES_HOST=postgres \ + -e POSTGRES_PORT=5432 \ + -e POSTGRES_DATABASE=mcpdb \ + -e POSTGRES_USER=mcpuser \ + -e POSTGRES_PASSWORD=mcppass \ + us-central1-docker.pkg.dev/database-toolbox/toolbox/toolbox:latest \ + --prebuilt postgres --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 "execute_sql"; 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..bec228c --- /dev/null +++ b/demo-applications/spring-boot-postgres/scripts/stop-containers.sh @@ -0,0 +1,17 @@ +#!/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. + +docker rm -f mcp-toolbox mcp-postgres 2>/dev/null || true +echo "==> Test containers 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..a3ab0ac --- /dev/null +++ b/demo-applications/spring-boot-postgres/src/main/java/com/google/cloud/mcp/example/controller/ProductController.java @@ -0,0 +1,104 @@ +/* + * 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.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.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)); + } + + /** + * Creates a new product. + * + * @param product Product details. + * @return HTTP 201 Created. + */ + @PostMapping("/products") + public CompletableFuture> createProduct(@RequestBody Product product) { + return catalogService + .addProduct( + product.name(), + product.category(), + product.price() != null ? product.price() : 0.0, + product.stock() != null ? product.stock() : 0) + .thenApply(v -> ResponseEntity.status(HttpStatus.CREATED).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..9a34c88 --- /dev/null +++ b/demo-applications/spring-boot-postgres/src/main/java/com/google/cloud/mcp/example/service/ProductCatalogService.java @@ -0,0 +1,210 @@ +/* + * 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.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.List; +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +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() + .thenApply( + 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 'execute_sql' tool. + * + * @return CompletableFuture containing list of {@link Product} objects. + */ + public CompletableFuture> getAllProducts() { + String sql = "SELECT id, name, category, price, stock FROM products ORDER BY id;"; + logger.debug("Executing SQL via MCP: {}", sql); + + return client + .invokeTool("execute_sql", Map.of("sql", sql)) + .thenApply(this::parseProductsResult); + } + + /** + * Inserts a new product into the database using the 'execute_sql' 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 completing when the record has been persisted. + */ + 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")); + } + + String sanitizedName = sanitizeSqlString(trimmedName); + String categorySql = + (trimmedCategory != null && !trimmedCategory.isEmpty()) + ? "'" + sanitizeSqlString(trimmedCategory) + "'" + : "NULL"; + String sql = + String.format( + Locale.US, + "INSERT INTO products (name, category, price, stock) VALUES ('%s', %s, %.2f, %d);", + sanitizedName, + categorySql, + price, + stock); + + logger.debug("Executing insert SQL via MCP: {}", sql); + + return client + .invokeTool("execute_sql", Map.of("sql", sql)) + .thenAccept( + result -> { + if (result.isError()) { + String errorMsg = extractErrorMessage(result); + logger.error("Failed to insert product: {}", errorMsg); + throw new IllegalStateException("Tool execution failed: " + errorMsg); + } + logger.info("Product successfully persisted: {}", trimmedName); + }); + } + + /** + * Introspects table existence and metadata using the 'list_tables' tool. + * + * @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); + Map args = + tableName != null ? Map.of("table_names", tableName) : Collections.emptyMap(); + + return client + .invokeTool("list_tables", args) + .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("execute_sql 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.warn("Could not deserialize content as Product: {}", trimmed, e); + } + } + } + return products; + } + + private String sanitizeSqlString(String input) { + return input.replace("\0", "").replace("'", "''"); + } + + 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..53b8afe --- /dev/null +++ b/demo-applications/spring-boot-postgres/src/main/resources/schema.sql @@ -0,0 +1,15 @@ +-- 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, + stock INT NOT NULL +); + +-- 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/SpringBootPostgresApplicationTests.java b/demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/SpringBootPostgresApplicationTests.java new file mode 100644 index 0000000..d2e6ad1 --- /dev/null +++ b/demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/SpringBootPostgresApplicationTests.java @@ -0,0 +1,201 @@ +/* + * 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.concurrent.TimeUnit; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestMethodOrder; +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 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) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) +@Timeout(value = 30, unit = TimeUnit.SECONDS) +class SpringBootPostgresApplicationTests { + + @Autowired private McpToolboxClient mcpToolboxClient; + + @Autowired private ProductCatalogService catalogService; + + @Autowired private TestRestTemplate restTemplate; + + @Test + @Order(1) + @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 + @Order(2) + @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("execute_sql", "list_tables", "database_overview"); + } + + @Test + @Order(3) + @DisplayName("Service retrieves seeded products via execute_sql 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 + @Order(4) + @DisplayName("Service persists new product via execute_sql and queries it back") + void testInsertProduct_PersistsAndCanBeQueried() { + String testItemName = "High-Precision Gaming Mouse"; + catalogService.addProduct(testItemName, "Gaming", 79.99, 50).join(); + + List updatedProducts = catalogService.getAllProducts().join(); + assertThat(updatedProducts.stream().map(Product::name).toList()).contains(testItemName); + } + + @Test + @Order(5) + @DisplayName("Service introspects table schema via list_tables 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(); + assertThat(schemaOutput).contains("products"); + } + + @Test + @Order(6) + @DisplayName("SDK client handles invalid SQL execution gracefully") + void testExecuteSql_InvalidSql_HandlesErrorGracefully() { + ToolResult result = + mcpToolboxClient + .invokeTool( + "execute_sql", Map.of("sql", "SELECT * FROM non_existent_test_table_12345;")) + .join(); + + assertNotNull(result, "ToolResult should not be null"); + assertTrue(result.isError(), "Result should report error flag for invalid table query"); + assertThat(result.content()).isNotEmpty(); + } + + @Test + @Order(7) + @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("execute_sql"); + } + + @Test + @Order(8) + @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 + @Order(9) + @DisplayName("REST Controller POST /api/products creates a product") + void testRestController_PostProduct() { + Product newProduct = new Product(null, "Wireless Earbuds Pro", "Audio", 199.99, 85); + + ResponseEntity response = + restTemplate.postForEntity("/api/products", newProduct, Void.class); + + assertThat(response.getStatusCode()).isEqualTo(HttpStatus.CREATED); + + List products = catalogService.getAllProducts().join(); + assertThat(products.stream().map(Product::name).toList()).contains("Wireless Earbuds Pro"); + } + + @Test + @Order(10) + @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 + @Order(11) + @DisplayName("Service handles null category by storing SQL NULL") + void testInsertProduct_NullCategory_PersistsAsNull() { + String itemName = "Uncategorized Item"; + catalogService.addProduct(itemName, null, 19.99, 10).join(); + + List products = catalogService.getAllProducts().join(); + Product found = + products.stream().filter(p -> itemName.equals(p.name())).findFirst().orElse(null); + assertNotNull(found); + assertThat(found.category()).isNull(); + } +} 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..5c00b70 --- /dev/null +++ b/demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/service/ProductCatalogServiceTest.java @@ -0,0 +1,227 @@ +/* + * 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("execute_sql"), 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("execute_sql"), 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("addProduct formats SQL with sanitized values and quotes") + void testAddProduct_Valid_FormatsSqlWithCategory() { + ToolResult successResult = + new ToolResult(List.of(new ToolResult.Content("text", "INSERT 0 1")), false); + when(mockClient.invokeTool(eq("execute_sql"), any())) + .thenReturn(CompletableFuture.completedFuture(successResult)); + + service.addProduct("O'Reilly Book", "Books", 49.99, 10).join(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(mockClient).invokeTool(eq("execute_sql"), captor.capture()); + + String sql = (String) captor.getValue().get("sql"); + assertThat(sql).contains("VALUES ('O''Reilly Book', 'Books', 49.99, 10);"); + } + + @Test + @DisplayName("addProduct formats null or empty category as SQL NULL literal") + void testAddProduct_Valid_FormatsSqlWithNullCategory() { + ToolResult successResult = + new ToolResult(List.of(new ToolResult.Content("text", "INSERT 0 1")), false); + when(mockClient.invokeTool(eq("execute_sql"), any())) + .thenReturn(CompletableFuture.completedFuture(successResult)); + + service.addProduct("Generic Item", null, 9.99, 5).join(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(mockClient).invokeTool(eq("execute_sql"), captor.capture()); + + String sql = (String) captor.getValue().get("sql"); + assertThat(sql).contains("VALUES ('Generic Item', NULL, 9.99, 5);"); + } + + @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_names' parameter to list_tables tool") + void testGetTableSchema_PassesTableNamesParam() { + ToolResult schemaResult = + new ToolResult(List.of(new ToolResult.Content("text", "{\"table\":\"products\"}")), false); + when(mockClient.invokeTool(eq("list_tables"), any())) + .thenReturn(CompletableFuture.completedFuture(schemaResult)); + + String schema = service.getTableSchema("products").join(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); + verify(mockClient).invokeTool(eq("list_tables"), captor.capture()); + + assertEquals("products", captor.getValue().get("table_names")); + assertThat(schema).contains("products"); + } + + @Test + @DisplayName("Tool execution error throws IllegalStateException") + void testToolExecutionError_ThrowsIllegalStateException() { + ToolResult errorResult = + new ToolResult(List.of(new ToolResult.Content("text", "SQL error syntax")), true); + when(mockClient.invokeTool(eq("execute_sql"), any())) + .thenReturn(CompletableFuture.completedFuture(errorResult)); + + CompletionException ex = + assertThrows(CompletionException.class, () -> service.getAllProducts().join()); + assertThat(ex.getCause()).isInstanceOf(IllegalStateException.class); + assertThat(ex.getCause().getMessage()).contains("SQL error syntax"); + } +} From 3d7bd31a9d1a7ed5cb4401af155a1a6dded75964 Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Thu, 10 Sep 2026 12:46:46 +0530 Subject: [PATCH 2/6] feat: add declarative tools.yaml configuration for MCP Toolbox - Define domain-specific PostgreSQL tools in tools.yaml (get-all-products, get-product-by-id, get-products-by-category, add-product, delete-product-by-id, list_tables, get-table-schema) - Update ProductCatalogService to invoke declarative tools with typed arguments instead of raw SQL strings - Update scripts/start-containers.sh to mount tools.yaml and execute toolbox with --config - Update unit tests (ProductCatalogServiceTest) and live integration tests (SpringBootPostgresApplicationTests) to assert declarative tool behavior - Document declarative tools architecture and configuration in README.md --- .../spring-boot-postgres/README.md | 26 ++++- .../scripts/start-containers.sh | 7 +- .../service/ProductCatalogService.java | 86 +++++++++----- .../SpringBootPostgresApplicationTests.java | 25 +++-- .../service/ProductCatalogServiceTest.java | 100 ++++++++++++----- .../spring-boot-postgres/tools.yaml | 106 ++++++++++++++++++ 6 files changed, 280 insertions(+), 70 deletions(-) create mode 100644 demo-applications/spring-boot-postgres/tools.yaml diff --git a/demo-applications/spring-boot-postgres/README.md b/demo-applications/spring-boot-postgres/README.md index 143c3de..1e405d7 100644 --- a/demo-applications/spring-boot-postgres/README.md +++ b/demo-applications/spring-boot-postgres/README.md @@ -2,7 +2,7 @@ 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, which exposes prebuilt database tools (`execute_sql`, `list_tables`, `database_overview`, etc.) backed by a live **PostgreSQL** instance. +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. --- @@ -21,7 +21,8 @@ The application connects to an official [MCP Toolbox](https://github.com/googlea ┌─────────────────────────────────┐ │ MCP Toolbox Server (Docker) │ │ (Port 5005:5000) │ -│ --prebuilt postgres │ +│ --config /tools.yaml │ +│ (Custom declarative tools) │ └────────────────┬────────────────┘ │ TCP (5432) ▼ @@ -34,6 +35,27 @@ The application connects to an official [MCP Toolbox](https://github.com/googlea --- +## 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. + +--- + ## Prerequisites - **Java 17+** (JDK 17, 21, or 26) diff --git a/demo-applications/spring-boot-postgres/scripts/start-containers.sh b/demo-applications/spring-boot-postgres/scripts/start-containers.sh index fefbcf6..e999c9f 100755 --- a/demo-applications/spring-boot-postgres/scripts/start-containers.sh +++ b/demo-applications/spring-boot-postgres/scripts/start-containers.sh @@ -34,19 +34,20 @@ 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 mcpuser -d mcpdb < "${SCRIPT_DIR}/../src/main/resources/schema.sql" -echo "==> Starting MCP Toolbox container (port 5005:5000)..." +echo "==> Starting MCP Toolbox container with custom tools.yaml (port 5005:5000)..." docker run -d --name mcp-toolbox --link mcp-postgres:postgres -p 5005:5000 \ -e POSTGRES_HOST=postgres \ -e POSTGRES_PORT=5432 \ -e POSTGRES_DATABASE=mcpdb \ -e POSTGRES_USER=mcpuser \ -e POSTGRES_PASSWORD=mcppass \ + -v "${SCRIPT_DIR}/../tools.yaml:/tools.yaml:ro" \ us-central1-docker.pkg.dev/database-toolbox/toolbox/toolbox:latest \ - --prebuilt postgres --address 0.0.0.0 --port 5000 + --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 "execute_sql"; do + -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' | grep -q "get-all-products"; do sleep 1 done 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 index 9a34c88..976a0ad 100644 --- 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 @@ -23,8 +23,8 @@ 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.Locale; import java.util.Map; import java.util.concurrent.CompletableFuture; import org.slf4j.Logger; @@ -62,21 +62,55 @@ public CompletableFuture> listAvailableTools() { } /** - * Retrieves all products from the PostgreSQL database using the 'execute_sql' tool. + * Retrieves all products from the PostgreSQL database using the 'get-all-products' tool. * * @return CompletableFuture containing list of {@link Product} objects. */ public CompletableFuture> getAllProducts() { - String sql = "SELECT id, name, category, price, stock FROM products ORDER BY id;"; - logger.debug("Executing SQL via MCP: {}", sql); + logger.debug("Invoking 'get-all-products' tool via MCP"); + return client + .invokeTool("get-all-products", Collections.emptyMap()) + .thenApply(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(int id) { + if (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("execute_sql", Map.of("sql", sql)) + .invokeTool("get-product-by-id", Map.of("id", id)) + .thenApply(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)) .thenApply(this::parseProductsResult); } /** - * Inserts a new product into the database using the 'execute_sql' tool. + * 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). @@ -110,24 +144,18 @@ public CompletableFuture addProduct(String name, String category, double p new IllegalArgumentException("Product stock must be non-negative")); } - String sanitizedName = sanitizeSqlString(trimmedName); - String categorySql = - (trimmedCategory != null && !trimmedCategory.isEmpty()) - ? "'" + sanitizeSqlString(trimmedCategory) + "'" - : "NULL"; - String sql = - String.format( - Locale.US, - "INSERT INTO products (name, category, price, stock) VALUES ('%s', %s, %.2f, %d);", - sanitizedName, - categorySql, - price, - stock); - - logger.debug("Executing insert SQL via MCP: {}", sql); + 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("execute_sql", Map.of("sql", sql)) + .invokeTool("add-product", arguments) .thenAccept( result -> { if (result.isError()) { @@ -140,18 +168,20 @@ public CompletableFuture addProduct(String name, String category, double p } /** - * Introspects table existence and metadata using the 'list_tables' tool. + * 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 = - tableName != null ? Map.of("table_names", tableName) : Collections.emptyMap(); + hasTable ? Map.of("table_name", tableName.trim()) : Collections.emptyMap(); return client - .invokeTool("list_tables", args) + .invokeTool(toolName, args) .thenApply( result -> { if (result.isError()) { @@ -168,7 +198,7 @@ public CompletableFuture getTableSchema(String tableName) { private List parseProductsResult(ToolResult result) { if (result.isError()) { String errorMsg = extractErrorMessage(result); - logger.error("execute_sql returned error: {}", errorMsg); + logger.error("Tool query returned error: {}", errorMsg); throw new IllegalStateException("Query failed: " + errorMsg); } @@ -197,10 +227,6 @@ private List parseProductsResult(ToolResult result) { return products; } - private String sanitizeSqlString(String input) { - return input.replace("\0", "").replace("'", "''"); - } - private String extractErrorMessage(ToolResult result) { if (result.content() != null && !result.content().isEmpty()) { return result.content().get(0).text(); diff --git a/demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/SpringBootPostgresApplicationTests.java b/demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/SpringBootPostgresApplicationTests.java index d2e6ad1..6468379 100644 --- a/demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/SpringBootPostgresApplicationTests.java +++ b/demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/SpringBootPostgresApplicationTests.java @@ -75,12 +75,18 @@ void testToolDiscovery_ContainsPostgresTools() { Map tools = mcpToolboxClient.listTools().join(); assertNotNull(tools, "Discovered tools map should not be null"); assertThat(tools).isNotEmpty(); - assertThat(tools.keySet()).contains("execute_sql", "list_tables", "database_overview"); + assertThat(tools.keySet()) + .contains( + "get-all-products", + "get-product-by-id", + "add-product", + "list_tables", + "get-table-schema"); } @Test @Order(3) - @DisplayName("Service retrieves seeded products via execute_sql tool") + @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"); @@ -93,7 +99,7 @@ void testQueryProducts_ReturnsSeededItems() { @Test @Order(4) - @DisplayName("Service persists new product via execute_sql and queries it back") + @DisplayName("Service persists new product via add-product tool and queries it back") void testInsertProduct_PersistsAndCanBeQueried() { String testItemName = "High-Precision Gaming Mouse"; catalogService.addProduct(testItemName, "Gaming", 79.99, 50).join(); @@ -104,7 +110,7 @@ void testInsertProduct_PersistsAndCanBeQueried() { @Test @Order(5) - @DisplayName("Service introspects table schema via list_tables tool") + @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"); @@ -115,16 +121,15 @@ void testListTables_DiscoversProductsTable() throws Exception { @Test @Order(6) - @DisplayName("SDK client handles invalid SQL execution gracefully") - void testExecuteSql_InvalidSql_HandlesErrorGracefully() { + @DisplayName("SDK client handles invalid tool execution gracefully") + void testExecuteTool_InvalidArguments_HandlesErrorGracefully() { ToolResult result = mcpToolboxClient - .invokeTool( - "execute_sql", Map.of("sql", "SELECT * FROM non_existent_test_table_12345;")) + .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 table query"); + assertTrue(result.isError(), "Result should report error flag for invalid argument type"); assertThat(result.content()).isNotEmpty(); } @@ -136,7 +141,7 @@ void testRestController_GetTools() { assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); assertNotNull(response.getBody()); - assertThat(response.getBody()).contains("execute_sql"); + assertThat(response.getBody()).contains("get-all-products", "add-product"); } @Test 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 index 5c00b70..5873899 100644 --- 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 @@ -87,7 +87,7 @@ void testGetAllProducts_SingleObjects() { "{\"id\":2,\"name\":\"Keyboard\",\"category\":null,\"price\":89.99,\"stock\":50}")), false); - when(mockClient.invokeTool(eq("execute_sql"), any())) + when(mockClient.invokeTool(eq("get-all-products"), any())) .thenReturn(CompletableFuture.completedFuture(result)); List products = service.getAllProducts().join(); @@ -106,7 +106,7 @@ void testGetAllProducts_JsonArray() { + " Lamp\",\"category\":\"Lighting\",\"price\":39.99,\"stock\":40}]"; ToolResult result = new ToolResult(List.of(new ToolResult.Content("text", jsonArray)), false); - when(mockClient.invokeTool(eq("execute_sql"), any())) + when(mockClient.invokeTool(eq("get-all-products"), any())) .thenReturn(CompletableFuture.completedFuture(result)); List products = service.getAllProducts().join(); @@ -117,39 +117,88 @@ void testGetAllProducts_JsonArray() { } @Test - @DisplayName("addProduct formats SQL with sanitized values and quotes") - void testAddProduct_Valid_FormatsSqlWithCategory() { + @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(1).join(); + + assertThat(product).isNotNull(); + assertEquals(1, product.id()); + assertEquals("Quantum Laptop", product.name()); + } + + @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") + void testAddProduct_Valid_PassesArgumentsWithCategory() { ToolResult successResult = - new ToolResult(List.of(new ToolResult.Content("text", "INSERT 0 1")), false); - when(mockClient.invokeTool(eq("execute_sql"), any())) + new ToolResult(List.of(new ToolResult.Content("text", "{\"id\":10}")), false); + when(mockClient.invokeTool(eq("add-product"), any())) .thenReturn(CompletableFuture.completedFuture(successResult)); service.addProduct("O'Reilly Book", "Books", 49.99, 10).join(); @SuppressWarnings("unchecked") ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); - verify(mockClient).invokeTool(eq("execute_sql"), captor.capture()); + verify(mockClient).invokeTool(eq("add-product"), captor.capture()); - String sql = (String) captor.getValue().get("sql"); - assertThat(sql).contains("VALUES ('O''Reilly Book', 'Books', 49.99, 10);"); + 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 formats null or empty category as SQL NULL literal") - void testAddProduct_Valid_FormatsSqlWithNullCategory() { + @DisplayName("addProduct omits category key when null or empty") + void testAddProduct_Valid_OmitsNullCategory() { ToolResult successResult = - new ToolResult(List.of(new ToolResult.Content("text", "INSERT 0 1")), false); - when(mockClient.invokeTool(eq("execute_sql"), any())) + new ToolResult(List.of(new ToolResult.Content("text", "{\"id\":11}")), false); + when(mockClient.invokeTool(eq("add-product"), any())) .thenReturn(CompletableFuture.completedFuture(successResult)); service.addProduct("Generic Item", null, 9.99, 5).join(); @SuppressWarnings("unchecked") ArgumentCaptor> captor = ArgumentCaptor.forClass(Map.class); - verify(mockClient).invokeTool(eq("execute_sql"), captor.capture()); + verify(mockClient).invokeTool(eq("add-product"), captor.capture()); - String sql = (String) captor.getValue().get("sql"); - assertThat(sql).contains("VALUES ('Generic Item', NULL, 9.99, 5);"); + 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 @@ -194,20 +243,21 @@ private void assertValidationFailure(CompletableFuture future, String expecte } @Test - @DisplayName("getTableSchema passes 'table_names' parameter to list_tables tool") - void testGetTableSchema_PassesTableNamesParam() { + @DisplayName("getTableSchema passes 'table_name' parameter to get-table-schema tool") + void testGetTableSchema_PassesTableNameParam() { ToolResult schemaResult = - new ToolResult(List.of(new ToolResult.Content("text", "{\"table\":\"products\"}")), false); - when(mockClient.invokeTool(eq("list_tables"), any())) + 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("list_tables"), captor.capture()); + verify(mockClient).invokeTool(eq("get-table-schema"), captor.capture()); - assertEquals("products", captor.getValue().get("table_names")); + assertEquals("products", captor.getValue().get("table_name")); assertThat(schema).contains("products"); } @@ -215,13 +265,13 @@ void testGetTableSchema_PassesTableNamesParam() { @DisplayName("Tool execution error throws IllegalStateException") void testToolExecutionError_ThrowsIllegalStateException() { ToolResult errorResult = - new ToolResult(List.of(new ToolResult.Content("text", "SQL error syntax")), true); - when(mockClient.invokeTool(eq("execute_sql"), any())) + 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("SQL error syntax"); + assertThat(ex.getCause().getMessage()).contains("Tool invocation failed"); } } diff --git a/demo-applications/spring-boot-postgres/tools.yaml b/demo-applications/spring-boot-postgres/tools.yaml new file mode 100644 index 0000000..22f7391 --- /dev/null +++ b/demo-applications/spring-boot-postgres/tools.yaml @@ -0,0 +1,106 @@ +# 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. + 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. + 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. + - name: category + type: string + description: Optional category of the product. + required: false + - name: price + type: float + description: Retail price of the product. + - name: stock + type: integer + description: Stock quantity available. + 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. + 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. + 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; From 879bd82baf1861b1b485cf52ea1952eb9211eea1 Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Thu, 10 Sep 2026 13:05:31 +0530 Subject: [PATCH 3/6] feat: harden reactive concurrency, database constraints, and REST contracts - Add .orTimeout(10, TimeUnit.SECONDS) across all asynchronous MCP client calls - Offload CPU-bound Jackson deserialization to ForkJoinPool via .thenApplyAsync - Enforce database integrity with price/stock CHECK constraints and category index in schema.sql - Mark mandatory parameters with explicit required: true in tools.yaml - Wire delete-product-by-id end-to-end through service and DELETE /api/products/{id} endpoint - Refactor POST /api/products to return 201 Created with Location header and entity body - Make integration tests hermetic without sequential @TestMethodOrder coupling - Eliminate flaky substring checks with Jackson JsonNode structural assertions - Parameterize credentials in start-containers.sh with environment variable defaults --- .../scripts/start-containers.sh | 20 +-- .../example/controller/ProductController.java | 86 ++++++++++-- .../service/ProductCatalogService.java | 62 +++++++-- .../src/main/resources/schema.sql | 6 +- .../SpringBootPostgresApplicationTests.java | 127 +++++++++++++----- .../service/ProductCatalogServiceTest.java | 81 +++++++++-- .../spring-boot-postgres/tools.yaml | 7 + 7 files changed, 318 insertions(+), 71 deletions(-) diff --git a/demo-applications/spring-boot-postgres/scripts/start-containers.sh b/demo-applications/spring-boot-postgres/scripts/start-containers.sh index e999c9f..0593d03 100755 --- a/demo-applications/spring-boot-postgres/scripts/start-containers.sh +++ b/demo-applications/spring-boot-postgres/scripts/start-containers.sh @@ -18,29 +18,33 @@ set -euo pipefail echo "==> Cleaning up previous test containers if running..." docker rm -f mcp-toolbox mcp-postgres 2>/dev/null || true +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 -p 5433:5432 \ - -e POSTGRES_USER=mcpuser \ - -e POSTGRES_PASSWORD=mcppass \ - -e POSTGRES_DB=mcpdb \ + -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 mcpuser -d mcpdb; do +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 mcpuser -d mcpdb < "${SCRIPT_DIR}/../src/main/resources/schema.sql" +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 --link mcp-postgres:postgres -p 5005:5000 \ -e POSTGRES_HOST=postgres \ -e POSTGRES_PORT=5432 \ - -e POSTGRES_DATABASE=mcpdb \ - -e POSTGRES_USER=mcpuser \ - -e POSTGRES_PASSWORD=mcppass \ + -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 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 index a3ab0ac..c992b54 100644 --- 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 @@ -18,11 +18,13 @@ 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; @@ -62,21 +64,89 @@ 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 int id) { + if (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. + * @return HTTP 201 Created with Location header and persisted product. */ @PostMapping("/products") - public CompletableFuture> createProduct(@RequestBody Product product) { + 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 int id) { + if (id <= 0) { + return CompletableFuture.failedFuture( + new IllegalArgumentException("Product ID must be positive")); + } return catalogService - .addProduct( - product.name(), - product.category(), - product.price() != null ? product.price() : 0.0, - product.stock() != null ? product.stock() : 0) - .thenApply(v -> ResponseEntity.status(HttpStatus.CREATED).build()); + .deleteProductById(id) + .thenApply( + deleted -> + deleted ? ResponseEntity.noContent().build() : ResponseEntity.notFound().build()); } /** 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 index 976a0ad..f67b676 100644 --- 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 @@ -27,6 +27,7 @@ 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; @@ -53,7 +54,8 @@ public ProductCatalogService(McpToolboxClient client, ObjectMapper objectMapper) public CompletableFuture> listAvailableTools() { return client .listTools() - .thenApply( + .orTimeout(10, TimeUnit.SECONDS) + .thenApplyAsync( tools -> { List names = tools.keySet().stream().sorted().toList(); logger.debug("Discovered {} tools: {}", names.size(), names); @@ -70,7 +72,8 @@ public CompletableFuture> getAllProducts() { logger.debug("Invoking 'get-all-products' tool via MCP"); return client .invokeTool("get-all-products", Collections.emptyMap()) - .thenApply(this::parseProductsResult); + .orTimeout(10, TimeUnit.SECONDS) + .thenApplyAsync(this::parseProductsResult); } /** @@ -87,7 +90,8 @@ public CompletableFuture getProductById(int id) { logger.debug("Invoking 'get-product-by-id' tool via MCP for id: {}", id); return client .invokeTool("get-product-by-id", Map.of("id", id)) - .thenApply(this::parseProductsResult) + .orTimeout(10, TimeUnit.SECONDS) + .thenApplyAsync(this::parseProductsResult) .thenApply(products -> products.isEmpty() ? null : products.get(0)); } @@ -106,7 +110,8 @@ public CompletableFuture> getProductsByCategory(String category) { logger.debug("Invoking 'get-products-by-category' tool via MCP for: {}", trimmedCategory); return client .invokeTool("get-products-by-category", Map.of("category", trimmedCategory)) - .thenApply(this::parseProductsResult); + .orTimeout(10, TimeUnit.SECONDS) + .thenApplyAsync(this::parseProductsResult); } /** @@ -116,9 +121,10 @@ public CompletableFuture> getProductsByCategory(String category) { * @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 completing when the record has been persisted. + * @return CompletableFuture containing the newly persisted {@link Product}. */ - public CompletableFuture addProduct(String name, String category, double price, int stock) { + 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")); @@ -156,14 +162,47 @@ public CompletableFuture addProduct(String name, String category, double p return client .invokeTool("add-product", arguments) - .thenAccept( + .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(int id) { + if (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 insert product: {}", errorMsg); + logger.error("Failed to delete product {}: {}", id, errorMsg); throw new IllegalStateException("Tool execution failed: " + errorMsg); } - logger.info("Product successfully persisted: {}", trimmedName); + if (result.content() != null && !result.content().isEmpty()) { + String text = result.content().get(0).text(); + return text != null && text.contains("\"id\""); + } + return false; }); } @@ -182,6 +221,7 @@ public CompletableFuture getTableSchema(String tableName) { return client .invokeTool(toolName, args) + .orTimeout(10, TimeUnit.SECONDS) .thenApply( result -> { if (result.isError()) { @@ -220,7 +260,9 @@ private List parseProductsResult(ToolResult result) { products.add(product); } } catch (JsonProcessingException e) { - logger.warn("Could not deserialize content as Product: {}", trimmed, e); + logger.error("Could not deserialize content as Product: {}", trimmed, e); + throw new IllegalStateException( + "Failed to deserialize product catalog payload: " + e.getMessage(), e); } } } diff --git a/demo-applications/spring-boot-postgres/src/main/resources/schema.sql b/demo-applications/spring-boot-postgres/src/main/resources/schema.sql index 53b8afe..6dfa2b0 100644 --- a/demo-applications/spring-boot-postgres/src/main/resources/schema.sql +++ b/demo-applications/spring-boot-postgres/src/main/resources/schema.sql @@ -3,10 +3,12 @@ CREATE TABLE IF NOT EXISTS products ( id SERIAL PRIMARY KEY, name VARCHAR(100) NOT NULL, category VARCHAR(50), - price NUMERIC(10,2) NOT NULL, - stock INT NOT NULL + 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), diff --git a/demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/SpringBootPostgresApplicationTests.java b/demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/SpringBootPostgresApplicationTests.java index 6468379..24cf666 100644 --- a/demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/SpringBootPostgresApplicationTests.java +++ b/demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/SpringBootPostgresApplicationTests.java @@ -29,12 +29,10 @@ 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.MethodOrderer; -import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.TestMethodOrder; import org.junit.jupiter.api.Timeout; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @@ -46,11 +44,10 @@ import org.springframework.http.ResponseEntity; /** - * End-to-end integration tests verifying Spring Boot integration with the MCP Toolbox Java SDK, - * connecting to a containerized MCP Toolbox server and PostgreSQL instance. + * 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) -@TestMethodOrder(MethodOrderer.OrderAnnotation.class) @Timeout(value = 30, unit = TimeUnit.SECONDS) class SpringBootPostgresApplicationTests { @@ -61,7 +58,6 @@ class SpringBootPostgresApplicationTests { @Autowired private TestRestTemplate restTemplate; @Test - @Order(1) @DisplayName("Context loads and McpToolboxClient bean is created") void testContextLoadsAndClientBeanConfigured() { assertNotNull(mcpToolboxClient, "McpToolboxClient bean should be present in context"); @@ -69,7 +65,6 @@ void testContextLoadsAndClientBeanConfigured() { } @Test - @Order(2) @DisplayName("SDK client discovers PostgreSQL tools from MCP Toolbox") void testToolDiscovery_ContainsPostgresTools() { Map tools = mcpToolboxClient.listTools().join(); @@ -80,12 +75,12 @@ void testToolDiscovery_ContainsPostgresTools() { "get-all-products", "get-product-by-id", "add-product", + "delete-product-by-id", "list_tables", "get-table-schema"); } @Test - @Order(3) @DisplayName("Service retrieves seeded products via get-all-products tool") void testQueryProducts_ReturnsSeededItems() { List products = catalogService.getAllProducts().join(); @@ -98,29 +93,35 @@ void testQueryProducts_ReturnsSeededItems() { } @Test - @Order(4) @DisplayName("Service persists new product via add-product tool and queries it back") void testInsertProduct_PersistsAndCanBeQueried() { - String testItemName = "High-Precision Gaming Mouse"; - catalogService.addProduct(testItemName, "Gaming", 79.99, 50).join(); + 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(); - List updatedProducts = catalogService.getAllProducts().join(); - assertThat(updatedProducts.stream().map(Product::name).toList()).contains(testItemName); + Product queried = catalogService.getProductById(created.id().intValue()).join(); + assertNotNull(queried); + assertThat(queried.name()).isEqualTo(uniqueItemName); + assertThat(queried.category()).isEqualTo("Gaming"); } @Test - @Order(5) @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(); - assertThat(schemaOutput).contains("products"); + 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 - @Order(6) @DisplayName("SDK client handles invalid tool execution gracefully") void testExecuteTool_InvalidArguments_HandlesErrorGracefully() { ToolResult result = @@ -134,7 +135,6 @@ void testExecuteTool_InvalidArguments_HandlesErrorGracefully() { } @Test - @Order(7) @DisplayName("REST Controller GET /api/tools returns available tools") void testRestController_GetTools() { ResponseEntity response = restTemplate.getForEntity("/api/tools", String[].class); @@ -145,7 +145,6 @@ void testRestController_GetTools() { } @Test - @Order(8) @DisplayName("REST Controller GET /api/products returns product list") void testRestController_GetProducts() { ResponseEntity response = @@ -157,22 +156,25 @@ void testRestController_GetProducts() { } @Test - @Order(9) - @DisplayName("REST Controller POST /api/products creates a product") + @DisplayName("REST Controller POST /api/products creates product with Location header and body") void testRestController_PostProduct() { - Product newProduct = new Product(null, "Wireless Earbuds Pro", "Audio", 199.99, 85); + 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, Void.class); + 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); - List products = catalogService.getAllProducts().join(); - assertThat(products.stream().map(Product::name).toList()).contains("Wireless Earbuds Pro"); + assertNotNull(response.getHeaders().getLocation()); + assertThat(response.getHeaders().getLocation().getPath()) + .isEqualTo("/api/products/" + response.getBody().id()); } @Test - @Order(10) @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); @@ -191,16 +193,71 @@ void testRestController_PostProduct_ValidationFailure() { } @Test - @Order(11) + @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 Item"; - catalogService.addProduct(itemName, null, 19.99, 10).join(); + 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(); + } - List products = catalogService.getAllProducts().join(); - Product found = - products.stream().filter(p -> itemName.equals(p.name())).findFirst().orElse(null); - assertNotNull(found); - assertThat(found.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 index 5873899..889c514 100644 --- 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 @@ -160,14 +160,24 @@ void testGetProductsByCategory_Success() { } @Test - @DisplayName("addProduct passes typed arguments map to add-product tool") + @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}")), false); + 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)); - service.addProduct("O'Reilly Book", "Books", 49.99, 10).join(); + 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); @@ -181,14 +191,23 @@ void testAddProduct_Valid_PassesArgumentsWithCategory() { } @Test - @DisplayName("addProduct omits category key when null or empty") + @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}")), false); + 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)); - service.addProduct("Generic Item", null, 9.99, 5).join(); + 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); @@ -244,7 +263,7 @@ private void assertValidationFailure(CompletableFuture future, String expecte @Test @DisplayName("getTableSchema passes 'table_name' parameter to get-table-schema tool") - void testGetTableSchema_PassesTableNameParam() { + void testGetTableSchema_PassesTableNameParam() throws Exception { ToolResult schemaResult = new ToolResult( List.of(new ToolResult.Content("text", "{\"table_name\":\"products\"}")), false); @@ -258,7 +277,23 @@ void testGetTableSchema_PassesTableNameParam() { verify(mockClient).invokeTool(eq("get-table-schema"), captor.capture()); assertEquals("products", captor.getValue().get("table_name")); - assertThat(schema).contains("products"); + 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 @@ -274,4 +309,34 @@ void testToolExecutionError_ThrowsIllegalStateException() { 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(1).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(999).join(); + assertThat(deleted).isFalse(); + } + + @Test + @DisplayName("deleteProductById rejects non-positive ID with IllegalArgumentException") + void testDeleteProductById_InvalidId() { + assertValidationFailure(service.deleteProductById(0), "ID must be positive"); + assertValidationFailure(service.deleteProductById(-1), "ID must be positive"); + } } diff --git a/demo-applications/spring-boot-postgres/tools.yaml b/demo-applications/spring-boot-postgres/tools.yaml index 22f7391..d363ee0 100644 --- a/demo-applications/spring-boot-postgres/tools.yaml +++ b/demo-applications/spring-boot-postgres/tools.yaml @@ -37,6 +37,7 @@ tools: - name: id type: integer description: The ID of the product. + required: true statement: | SELECT id, name, category, price, stock FROM products WHERE id = $1; @@ -48,6 +49,7 @@ tools: - 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; @@ -59,6 +61,7 @@ tools: - name: name type: string description: Name of the product. + required: true - name: category type: string description: Optional category of the product. @@ -66,9 +69,11 @@ tools: - 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; @@ -81,6 +86,7 @@ tools: - name: id type: integer description: The ID of the product to delete. + required: true statement: | DELETE FROM products WHERE id = $1 RETURNING id; @@ -99,6 +105,7 @@ tools: - 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 From ae9477805ee292e74c628e7df32534a427303367 Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Thu, 10 Sep 2026 20:17:43 +0530 Subject: [PATCH 4/6] chore: configure release-please for spring-boot-postgres example - Add release-please version annotation to mcp-toolbox-sdk-java dependency in pom.xml - Add Maven dependency code snippet with release-please annotation in README.md - Add demo-applications/spring-boot-postgres files to extra-files in release-please-config.json --- demo-applications/spring-boot-postgres/README.md | 12 ++++++++++++ demo-applications/spring-boot-postgres/pom.xml | 3 +-- release-please-config.json | 4 +++- 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/demo-applications/spring-boot-postgres/README.md b/demo-applications/spring-boot-postgres/README.md index 1e405d7..3b5600c 100644 --- a/demo-applications/spring-boot-postgres/README.md +++ b/demo-applications/spring-boot-postgres/README.md @@ -54,6 +54,18 @@ Rather than exposing arbitrary raw SQL execution, the application defines domain 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 diff --git a/demo-applications/spring-boot-postgres/pom.xml b/demo-applications/spring-boot-postgres/pom.xml index 6ac5a5a..60ce2a8 100644 --- a/demo-applications/spring-boot-postgres/pom.xml +++ b/demo-applications/spring-boot-postgres/pom.xml @@ -37,7 +37,6 @@ 17 17 UTF-8 - 1.0.0 @@ -51,7 +50,7 @@ com.google.cloud.mcp mcp-toolbox-sdk-java - ${mcp-toolbox-sdk.version} + 1.0.0 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" ] } }, From a8eb6756f878568b9485a8ded9084578d3d12352 Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Fri, 11 Sep 2026 21:38:29 +0530 Subject: [PATCH 5/6] refactor(demo): address review comments on spring-boot-postgres example - Align product ID type to Long across ProductCatalogService and ProductController - Robustly parse delete response JSON payload using ObjectMapper and verify deleted ID - Segregate standalone unit tests and container integration tests with Surefire and Failsafe - Replace deprecated Docker --link flag with user-defined bridge network in container scripts - Update README with segregated test commands --- .../spring-boot-postgres/README.md | 10 ++++- .../spring-boot-postgres/pom.xml | 24 ++++++++++++ .../scripts/start-containers.sh | 10 +++-- .../scripts/stop-containers.sh | 4 +- .../example/controller/ProductController.java | 8 ++-- .../service/ProductCatalogService.java | 25 ++++++++++--- ...a => SpringBootPostgresApplicationIT.java} | 4 +- .../service/ProductCatalogServiceTest.java | 37 +++++++++++++++---- 8 files changed, 97 insertions(+), 25 deletions(-) rename demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/{SpringBootPostgresApplicationTests.java => SpringBootPostgresApplicationIT.java} (99%) diff --git a/demo-applications/spring-boot-postgres/README.md b/demo-applications/spring-boot-postgres/README.md index 3b5600c..4882024 100644 --- a/demo-applications/spring-boot-postgres/README.md +++ b/demo-applications/spring-boot-postgres/README.md @@ -92,14 +92,20 @@ This script: 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 Suite +### 2. Run the Test Suites -Execute the integration test suite: +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 diff --git a/demo-applications/spring-boot-postgres/pom.xml b/demo-applications/spring-boot-postgres/pom.xml index 60ce2a8..438df9c 100644 --- a/demo-applications/spring-boot-postgres/pom.xml +++ b/demo-applications/spring-boot-postgres/pom.xml @@ -78,8 +78,32 @@ 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 index 0593d03..cce193d 100755 --- a/demo-applications/spring-boot-postgres/scripts/start-containers.sh +++ b/demo-applications/spring-boot-postgres/scripts/start-containers.sh @@ -15,15 +15,19 @@ set -euo pipefail -echo "==> Cleaning up previous test containers if running..." +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 -p 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}" \ @@ -39,7 +43,7 @@ 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 --link mcp-postgres:postgres -p 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}" \ diff --git a/demo-applications/spring-boot-postgres/scripts/stop-containers.sh b/demo-applications/spring-boot-postgres/scripts/stop-containers.sh index bec228c..51563ac 100755 --- a/demo-applications/spring-boot-postgres/scripts/stop-containers.sh +++ b/demo-applications/spring-boot-postgres/scripts/stop-containers.sh @@ -13,5 +13,7 @@ # 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 -echo "==> Test containers stopped and removed." +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/controller/ProductController.java b/demo-applications/spring-boot-postgres/src/main/java/com/google/cloud/mcp/example/controller/ProductController.java index c992b54..324e838 100644 --- 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 @@ -71,8 +71,8 @@ public CompletableFuture>> getAllProducts() { * @return The product or HTTP 404 Not Found. */ @GetMapping("/products/{id}") - public CompletableFuture> getProductById(@PathVariable int id) { - if (id <= 0) { + public CompletableFuture> getProductById(@PathVariable Long id) { + if (id == null || id <= 0) { return CompletableFuture.failedFuture( new IllegalArgumentException("Product ID must be positive")); } @@ -137,8 +137,8 @@ public CompletableFuture> createProduct(@RequestBody Pro * @return HTTP 204 No Content if deleted, or HTTP 404 Not Found. */ @DeleteMapping("/products/{id}") - public CompletableFuture> deleteProduct(@PathVariable int id) { - if (id <= 0) { + public CompletableFuture> deleteProduct(@PathVariable Long id) { + if (id == null || id <= 0) { return CompletableFuture.failedFuture( new IllegalArgumentException("Product ID must be positive")); } 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 index f67b676..331b699 100644 --- 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 @@ -17,6 +17,7 @@ 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; @@ -82,8 +83,8 @@ public CompletableFuture> getAllProducts() { * @param id The product ID (must be positive). * @return CompletableFuture containing {@link Product} or null if not found. */ - public CompletableFuture getProductById(int id) { - if (id <= 0) { + public CompletableFuture getProductById(Long id) { + if (id == null || id <= 0) { return CompletableFuture.failedFuture( new IllegalArgumentException("Product ID must be positive")); } @@ -182,8 +183,8 @@ public CompletableFuture addProduct( * @param id The product ID (must be positive). * @return CompletableFuture containing true if deleted, false otherwise. */ - public CompletableFuture deleteProductById(int id) { - if (id <= 0) { + public CompletableFuture deleteProductById(Long id) { + if (id == null || id <= 0) { return CompletableFuture.failedFuture( new IllegalArgumentException("Product ID must be positive")); } @@ -200,7 +201,21 @@ public CompletableFuture deleteProductById(int id) { } if (result.content() != null && !result.content().isEmpty()) { String text = result.content().get(0).text(); - return text != null && text.contains("\"id\""); + 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; }); diff --git a/demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/SpringBootPostgresApplicationTests.java b/demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/SpringBootPostgresApplicationIT.java similarity index 99% rename from demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/SpringBootPostgresApplicationTests.java rename to demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/SpringBootPostgresApplicationIT.java index 24cf666..7e2197e 100644 --- a/demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/SpringBootPostgresApplicationTests.java +++ b/demo-applications/spring-boot-postgres/src/test/java/com/google/cloud/mcp/example/SpringBootPostgresApplicationIT.java @@ -49,7 +49,7 @@ */ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @Timeout(value = 30, unit = TimeUnit.SECONDS) -class SpringBootPostgresApplicationTests { +class SpringBootPostgresApplicationIT { @Autowired private McpToolboxClient mcpToolboxClient; @@ -100,7 +100,7 @@ void testInsertProduct_PersistsAndCanBeQueried() { assertNotNull(created); assertThat(created.id()).isNotNull(); - Product queried = catalogService.getProductById(created.id().intValue()).join(); + Product queried = catalogService.getProductById(created.id()).join(); assertNotNull(queried); assertThat(queried.name()).isEqualTo(uniqueItemName); assertThat(queried.category()).isEqualTo("Gaming"); 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 index 889c514..4f7c511 100644 --- 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 @@ -131,13 +131,21 @@ void testGetProductById_Success() { when(mockClient.invokeTool(eq("get-product-by-id"), any())) .thenReturn(CompletableFuture.completedFuture(result)); - Product product = service.getProductById(1).join(); + Product product = service.getProductById(1L).join(); assertThat(product).isNotNull(); - assertEquals(1, product.id()); + 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() { @@ -318,25 +326,38 @@ void testDeleteProductById_Success() { when(mockClient.invokeTool(eq("delete-product-by-id"), any())) .thenReturn(CompletableFuture.completedFuture(deleteResult)); - boolean deleted = service.deleteProductById(1).join(); + 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); + 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(999).join(); + boolean deleted = service.deleteProductById(999L).join(); assertThat(deleted).isFalse(); } @Test - @DisplayName("deleteProductById rejects non-positive ID with IllegalArgumentException") + @DisplayName("deleteProductById rejects non-positive or null ID with IllegalArgumentException") void testDeleteProductById_InvalidId() { - assertValidationFailure(service.deleteProductById(0), "ID must be positive"); - assertValidationFailure(service.deleteProductById(-1), "ID must be positive"); + assertValidationFailure(service.deleteProductById(null), "ID must be positive"); + assertValidationFailure(service.deleteProductById(0L), "ID must be positive"); + assertValidationFailure(service.deleteProductById(-1L), "ID must be positive"); } } From f5a59fd6aa4ba6d1b9944155c7086040a39117a7 Mon Sep 17 00:00:00 2001 From: Stenal P Jolly Date: Fri, 11 Sep 2026 21:51:57 +0530 Subject: [PATCH 6/6] test(demo): cover malformed JSON fallback in deleteProductById Add a unit test asserting deleteProductById returns false when the delete-product-by-id tool returns an unparseable payload, exercising the previously uncovered JsonProcessingException branch. --- .../example/service/ProductCatalogServiceTest.java | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 index 4f7c511..d398e65 100644 --- 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 @@ -353,6 +353,18 @@ void testDeleteProductById_NotFound() { 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() {