From 48477ae597ee2fbee99babf12631e3a643465bc2 Mon Sep 17 00:00:00 2001 From: JinBa1 <72070041+JinBa1@users.noreply.github.com> Date: Sat, 20 Jun 2026 01:02:27 +0100 Subject: [PATCH] feat(server): expose the query engine to agents over MCP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layer a Model Context Protocol tool surface on the existing Spring Boot gateway using Spring AI 2.0.0 (WebMvc Streamable-HTTP, SYNC), so an agent can discover tables, inspect schemas, preview data and query cost, and run read-only SQL without writing SQL blind — and without bypassing the server's budget, concurrency, and audit guarantees. Engine module untouched; Java 17. - add CuckooMcpTools (@Component, five @McpTool methods: list_tables, describe_table, sample_rows, explain_query, query). Every query tool routes through QueryService.execute(...,"mcp") and every catalog tool through CatalogFacade, so MCP traffic inherits budget clamping, the concurrency permit, and the audit trail with no MCP-specific governance code - mirror the REST errorCode taxonomy onto CallToolResult.isError(true): engine errors are agent-legible and verbatim, while DATA_ERROR/INTERNAL are scrubbed to a generic message + correlation id (full detail logged server-side only) - extract the table-name charset guard (TableNameValidator) and the catalog-column -> DTO mapping (CatalogMapper) into the catalog package and delegate TableController to them, so the REST and MCP surfaces share one validation and one schema shape - import spring-ai-bom 2.0.0 + spring-ai-starter-mcp-server-webmvc (no reparent; the starter pulls the transport transitively) and configure the SYNC Streamable-HTTP server at /mcp (SSE is deprecated/broken on Boot 4 + Tomcat 11) - the bare @Component is auto-registered by the annotation scanner; no ToolCallbackProvider bean (the legacy @Tool path) is declared Verify with ./mvnw clean verify: engine 419 and server 90 passing tests, 0 failures; the 20 sample queries are byte-identical. The new tests cover the five tools at three tiers — plain JUnit, Mockito, and a @SpringBootTest that both drives the tools directly (choke-point routing, budget inheritance) and round-trips a real MCP client over the live /mcp endpoint on Tomcat 11. Known follow-up: tools pass the constant "mcp" principal (per-key MCP auth returns with Phase-4 governance), and a table-upload tool is deferred (it needs a write-path extraction). MCP schema generation relies on -parameters, so a clean build is required for correct tool argument names. --- AGENTS.md | 6 +- README.md | 6 +- server/pom.xml | 18 + .../server/catalog/CatalogMapper.java | 27 ++ .../server/catalog/TableNameValidator.java | 29 ++ .../cuckoodb/server/mcp/CuckooMcpTools.java | 215 +++++++++++ .../cuckoodb/server/web/TableController.java | 31 +- .../src/main/resources/application.properties | 12 + .../server/catalog/CatalogMapperTest.java | 47 +++ .../catalog/TableNameValidatorTest.java | 54 +++ .../server/mcp/CuckooMcpIntegrationTest.java | 180 +++++++++ .../server/mcp/CuckooMcpToolsTest.java | 345 ++++++++++++++++++ 12 files changed, 939 insertions(+), 31 deletions(-) create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/catalog/CatalogMapper.java create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/catalog/TableNameValidator.java create mode 100644 server/src/main/java/com/github/jinba1/cuckoodb/server/mcp/CuckooMcpTools.java create mode 100644 server/src/test/java/com/github/jinba1/cuckoodb/server/catalog/CatalogMapperTest.java create mode 100644 server/src/test/java/com/github/jinba1/cuckoodb/server/catalog/TableNameValidatorTest.java create mode 100644 server/src/test/java/com/github/jinba1/cuckoodb/server/mcp/CuckooMcpIntegrationTest.java create mode 100644 server/src/test/java/com/github/jinba1/cuckoodb/server/mcp/CuckooMcpToolsTest.java diff --git a/AGENTS.md b/AGENTS.md index eabbf20..6fd1876 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -30,9 +30,9 @@ cuckoodb-parent/ # repo root (git slug: java-query-engine) │ ├── ConcurrentQueryExecutionTest.java, DBCatalogTest.java, ... # planner / optimizer / budget / EXPLAIN / end-to-end │ ├── operator/ # operator-level tests + CachedOperator test utility │ └── bench/ # JMH benchmarks (compiled in CI, never run there): EndToEndJoinBenchmark, JoinAlgorithmBenchmark -└── server/ # cuckoodb-server — Spring Boot 4 REST gateway over the engine - ├── pom.xml # depends on cuckoodb-engine; Spring Boot 4.0.7 (web MVC), springdoc/OpenAPI - └── src/main/java/com/github/jinba1/cuckoodb/server/ # web/ controllers + GlobalExceptionHandler, query/ QueryService+budget+concurrency, catalog/ CatalogFacade, audit/ sink, config/ (52 server tests) +└── server/ # cuckoodb-server — Spring Boot 4 REST + MCP gateway over the engine + ├── pom.xml # depends on cuckoodb-engine; Spring Boot 4.0.7 (web MVC), springdoc/OpenAPI, Spring AI 2.0.0 MCP server + └── src/main/java/com/github/jinba1/cuckoodb/server/ # web/ controllers + GlobalExceptionHandler, query/ QueryService+budget+concurrency, catalog/ CatalogFacade, mcp/ CuckooMcpTools (5 @McpTool tools over the QueryService choke point) + TableNameValidator/CatalogMapper, audit/ sink, config/ (90 server tests) ``` Per-file responsibilities are in the WHERE TO LOOK table below. diff --git a/README.md b/README.md index 36e2188..2d74fdb 100644 --- a/README.md +++ b/README.md @@ -273,9 +273,9 @@ The test suite covers individual operators, the query planner, the optimiser, ex │ ├── db/data/ # CSV data files (header row + data rows) │ ├── input/query[1-20].sql # Sample queries │ └── expected_output/query[1-20].csv # Expected results -├── server/ # cuckoodb-server — Spring Boot REST gateway over the engine -│ ├── pom.xml # Spring Boot 4 (web MVC), springdoc/OpenAPI -│ └── src/main/java/com/github/jinba1/cuckoodb/server/ # controllers, query service, catalog facade, config +├── server/ # cuckoodb-server — Spring Boot REST + MCP gateway over the engine +│ ├── pom.xml # Spring Boot 4 (web MVC), springdoc/OpenAPI, Spring AI MCP server +│ └── src/main/java/com/github/jinba1/cuckoodb/server/ # web/ controllers, query/ service, catalog/ facade, mcp/ agent tools, config ├── mvnw / mvnw.cmd # Maven Wrapper └── LICENSE ``` diff --git a/server/pom.xml b/server/pom.xml index 2a2a6db..7854480 100644 --- a/server/pom.xml +++ b/server/pom.xml @@ -28,6 +28,8 @@ (full @SpringBootTest contexts load with springdoc auto-config; no MVC API-versioning, so springdoc #3163 does not apply). --> 3.0.3 + + 2.0.0 @@ -39,6 +41,13 @@ pom import + + org.springframework.ai + spring-ai-bom + ${spring-ai.version} + pom + import + @@ -57,6 +66,15 @@ springdoc-openapi-starter-webmvc-ui ${springdoc.version} + + + org.springframework.ai + spring-ai-starter-mcp-server-webmvc + org.springframework.boot spring-boot-starter-test diff --git a/server/src/main/java/com/github/jinba1/cuckoodb/server/catalog/CatalogMapper.java b/server/src/main/java/com/github/jinba1/cuckoodb/server/catalog/CatalogMapper.java new file mode 100644 index 0000000..87bfc58 --- /dev/null +++ b/server/src/main/java/com/github/jinba1/cuckoodb/server/catalog/CatalogMapper.java @@ -0,0 +1,27 @@ +package com.github.jinba1.cuckoodb.server.catalog; + +import com.github.jinba1.cuckoodb.server.web.dto.TableColumnDto; + +import java.util.ArrayList; +import java.util.List; + +/** + * Maps a base table's catalog-authoritative columns to the wire DTO, shared by the REST describe + * endpoint and the MCP {@code describe_table} tool so both render an identical schema shape. The + * type is the {@link com.github.jinba1.cuckoodb.ColumnType} enum name, or null when the catalog + * has no inferred type for a column. + */ +public final class CatalogMapper { + + private CatalogMapper() { + } + + /** Catalog columns → DTOs, in column order, preserving a null type as a null type string. */ + public static List toDto(List columns) { + List dtos = new ArrayList<>(columns.size()); + for (CatalogFacade.TableColumn c : columns) { + dtos.add(new TableColumnDto(c.name(), c.type() == null ? null : c.type().name())); + } + return dtos; + } +} diff --git a/server/src/main/java/com/github/jinba1/cuckoodb/server/catalog/TableNameValidator.java b/server/src/main/java/com/github/jinba1/cuckoodb/server/catalog/TableNameValidator.java new file mode 100644 index 0000000..95e6dfa --- /dev/null +++ b/server/src/main/java/com/github/jinba1/cuckoodb/server/catalog/TableNameValidator.java @@ -0,0 +1,29 @@ +package com.github.jinba1.cuckoodb.server.catalog; + +import java.util.regex.Pattern; + +/** + * The single table-name guard shared by every request surface (REST upload and MCP tools). The + * server is the only guard on a table name — the engine and {@code ScanOperator} use it + * verbatim to open a file — so every name is validated against this strict charset before it + * reaches a query or a filesystem path. Blocks path-traversal shapes (dot, slash) and + * SQL-injection bait (space, semicolon, dash) by construction. + */ +public final class TableNameValidator { + + /** The only table names the server will touch; blocks path traversal and odd characters. */ + private static final Pattern VALID_NAME = Pattern.compile("^[A-Za-z0-9_]{1,64}$"); + + private TableNameValidator() { + } + + /** + * @throws IllegalArgumentException if {@code name} is null or not {@code [A-Za-z0-9_]{1,64}} + */ + public static void validate(String name) { + if (name == null || !VALID_NAME.matcher(name).matches()) { + throw new IllegalArgumentException( + "Invalid table name '" + name + "'; must match [A-Za-z0-9_]{1,64}."); + } + } +} diff --git a/server/src/main/java/com/github/jinba1/cuckoodb/server/mcp/CuckooMcpTools.java b/server/src/main/java/com/github/jinba1/cuckoodb/server/mcp/CuckooMcpTools.java new file mode 100644 index 0000000..99b37b7 --- /dev/null +++ b/server/src/main/java/com/github/jinba1/cuckoodb/server/mcp/CuckooMcpTools.java @@ -0,0 +1,215 @@ +package com.github.jinba1.cuckoodb.server.mcp; + +import com.github.jinba1.cuckoodb.BudgetKind; +import com.github.jinba1.cuckoodb.ErrorCode; +import com.github.jinba1.cuckoodb.QueryBudgetExceededException; +import com.github.jinba1.cuckoodb.QueryExecutionException; +import com.github.jinba1.cuckoodb.server.catalog.CatalogFacade; +import com.github.jinba1.cuckoodb.server.catalog.CatalogMapper; +import com.github.jinba1.cuckoodb.server.catalog.TableNameValidator; +import com.github.jinba1.cuckoodb.server.query.ConcurrencyLimitExceededException; +import com.github.jinba1.cuckoodb.server.query.QueryService; +import com.github.jinba1.cuckoodb.server.query.QueryServiceResult; +import com.github.jinba1.cuckoodb.server.web.dto.QueryResponse; +import com.github.jinba1.cuckoodb.server.web.dto.TableSchemaResponse; + +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.ai.mcp.annotation.McpTool; +import org.springframework.ai.mcp.annotation.McpToolParam; +import org.springframework.stereotype.Component; + +import tools.jackson.databind.ObjectMapper; + +import java.util.List; +import java.util.UUID; + +/** + * The MCP tool surface over the query gateway: five agent-ergonomic tools that let an agent + * discover tables, inspect schemas, preview data and query cost, and run read-only SQL. + * + *

Choke-point rule. Every query-executing tool routes through + * {@link QueryService#execute}, and every catalog tool through {@link CatalogFacade}; no tool ever + * touches the engine directly. MCP traffic therefore inherits the server's budget clamping, + * concurrency permit, and audit trail for free — and per-key governance, when it lands inside + * {@code QueryService}, is inherited retroactively with no change to any tool signature. A tool + * that bypasses the choke point is a defect. + * + *

Auto-registered by the Spring AI annotation scanner because it is a bare {@code @Component} + * holding {@code @McpTool} methods — deliberately no {@code ToolCallbackProvider} bean + * (that is the legacy {@code @Tool} path and would fail startup here). Tools run on the SYNC + * WebMvc Streamable-HTTP transport, so each returns a non-reactive {@link CallToolResult}. + * + *

Errors mirror the REST {@code errorCode} taxonomy as a {@code CODE: message} prefix on an + * {@code isError(true)} result. Failures are caught and translated explicitly rather than left to + * the framework's exception auto-conversion, so a 5xx-class fault is scrubbed of filesystem paths + * exactly as {@code GlobalExceptionHandler} does for the REST surface. + */ +@Component +public class CuckooMcpTools { + + private static final Logger log = LoggerFactory.getLogger(CuckooMcpTools.class); + + /** Audit label for all MCP traffic; swapped for a per-principal resolver in a later phase. */ + private static final String MCP_PRINCIPAL = "mcp"; + + /** sample_rows bounds: a small preview by default, never an unbounded scan. */ + private static final int SAMPLE_DEFAULT = 10; + private static final int SAMPLE_MIN = 1; + private static final int SAMPLE_MAX = 1000; + + private final QueryService queryService; + private final CatalogFacade catalog; + private final ObjectMapper objectMapper; + + public CuckooMcpTools(QueryService queryService, CatalogFacade catalog, + ObjectMapper objectMapper) { + this.queryService = queryService; + this.catalog = catalog; + this.objectMapper = objectMapper; + } + + @McpTool(name = "list_tables", + description = "List all table names available in the database, sorted alphabetically. " + + "Call this first to discover what can be queried.") + public CallToolResult listTables() { + try { + return json(catalog.tableNames()); + } catch (Exception e) { + return translate(e); + } + } + + @McpTool(name = "describe_table", + description = "Return the column schema (each column's name and type, INT or STRING) of " + + "one table, so a query can be written against real columns.") + public CallToolResult describeTable( + @McpToolParam(description = "Exact table name (case-sensitive), as returned by list_tables.", + required = true) String name) { + try { + TableNameValidator.validate(name); + List columns = catalog.columnsOf(name) + .orElseThrow(() -> new QueryExecutionException( + ErrorCode.UNKNOWN_TABLE, "Table '" + name + "' not found.")); + return json(new TableSchemaResponse(name, CatalogMapper.toDto(columns))); + } catch (Exception e) { + return translate(e); + } + } + + @McpTool(name = "sample_rows", + description = "Return up to `limit` rows from a table as a quick data preview, without " + + "writing SQL. Use to inspect real values before querying.") + public CallToolResult sampleRows( + @McpToolParam(description = "Exact table name (case-sensitive), as returned by list_tables.", + required = true) String name, + @McpToolParam(description = "Maximum rows to return (1-1000, default 10).", + required = false) Integer limit) { + try { + // Validate BEFORE interpolation: the name is the only untrusted token in the SQL, so the + // charset check is the injection guard. A bad name never reaches the engine. + TableNameValidator.validate(name); + int n = clampLimit(limit); + String sql = "SELECT * FROM " + name + " LIMIT " + n; + // The LIMIT bounds the preview, and the engine scans lazily (it pulls only ~n+1 tuples, + // one extra to set the truncation flag), so no explicit tuple budget is needed — and a + // budget hugging n would trip on that truncation-detecting pull. Pass null to take the + // server's default budget as the backstop, exactly as a normal LIMIT query does. + QueryServiceResult result = queryService.execute(sql, null, null, MCP_PRINCIPAL); + return json(QueryResponse.fromResultSet(result.resultSet())); + } catch (Exception e) { + return translate(e); + } + } + + @McpTool(name = "explain_query", + description = "Return the query plan for a read-only SELECT without executing it or " + + "consuming any row budget. Use to preview a query's cost and shape before running it.") + public CallToolResult explainQuery( + @McpToolParam(description = "A read-only SELECT statement (do NOT prefix it with EXPLAIN).", + required = true) String sql) { + try { + // EXPLAIN returns the plan before any budget clamp, so null bounds are correct. + QueryServiceResult result = queryService.execute("EXPLAIN " + sql, null, null, MCP_PRINCIPAL); + return json(QueryResponse.fromExplain(result.explainText())); + } catch (Exception e) { + return translate(e); + } + } + + @McpTool(name = "query", + description = "Execute a read-only SELECT and return typed results as column metadata plus " + + "positional row arrays. Results are budget-bounded: a too-large result returns a " + + "BUDGET_EXCEEDED error (retry with a tighter LIMIT), a too-slow one a timeout.") + public CallToolResult query( + @McpToolParam(description = "A read-only SELECT statement.", required = true) String sql, + @McpToolParam(description = "Optional cap on rows scanned before aborting; clamped to the " + + "server cap. Omit to take the server default.", required = false) Long maxTuples, + @McpToolParam(description = "Optional wall-clock budget in milliseconds; clamped to the " + + "server cap. Omit to take the server default.", required = false) Long timeoutMs) { + try { + QueryServiceResult result = queryService.execute(sql, maxTuples, timeoutMs, MCP_PRINCIPAL); + // Defensive: a caller who prefixed EXPLAIN still gets the plan shape rather than an error. + return result.isExplain() + ? json(QueryResponse.fromExplain(result.explainText())) + : json(QueryResponse.fromResultSet(result.resultSet())); + } catch (Exception e) { + return translate(e); + } + } + + private static int clampLimit(Integer limit) { + if (limit == null) { + return SAMPLE_DEFAULT; + } + return Math.max(SAMPLE_MIN, Math.min(SAMPLE_MAX, limit)); + } + + /** Serializes a success payload to JSON text content via the injected (Jackson 3) mapper. */ + private CallToolResult json(Object payload) { + return CallToolResult.builder() + .addTextContent(objectMapper.writeValueAsString(payload)) + .build(); + } + + private static CallToolResult error(String text) { + return CallToolResult.builder().isError(true).addTextContent(text).build(); + } + + /** + * Maps a failure onto the REST {@code errorCode} taxonomy as an {@code isError(true)} text + * result. Client-actionable engine errors are returned verbatim (agent-legible, no paths); + * a 5xx-class fault is logged with a correlation id server-side and scrubbed on the wire. + * Audit is not duplicated here — {@code QueryService.runPlanned} already records the error + * before rethrowing; this only logs the scrubbed cases. + */ + private CallToolResult translate(Exception e) { + if (e instanceof QueryBudgetExceededException be) { + String prefix = be.kind() == BudgetKind.TIME ? "BUDGET_EXCEEDED (timeout)" : "BUDGET_EXCEEDED"; + return error(prefix + ": " + be.getMessage()); + } + if (e instanceof QueryExecutionException qe) { + return switch (qe.code()) { + case PARSE_ERROR, UNSUPPORTED_SQL, UNKNOWN_TABLE, UNKNOWN_COLUMN, TYPE_MISMATCH -> + error(qe.code().name() + ": " + qe.getMessage()); + case BUDGET_EXCEEDED -> error("BUDGET_EXCEEDED: " + qe.getMessage()); + case DATA_ERROR, INTERNAL -> scrubbed(qe.code(), qe); + }; + } + if (e instanceof ConcurrencyLimitExceededException) { + return error("CONCURRENCY_LIMIT: " + e.getMessage()); + } + if (e instanceof IllegalArgumentException) { + return error("BAD_REQUEST: " + e.getMessage()); + } + return scrubbed(ErrorCode.INTERNAL, e); + } + + private CallToolResult scrubbed(ErrorCode code, Exception e) { + String errorId = UUID.randomUUID().toString(); + log.error("MCP 5xx [{}] errorId={}", code, errorId, e); + return error("INTERNAL: Internal server error. errorId=" + errorId); + } +} diff --git a/server/src/main/java/com/github/jinba1/cuckoodb/server/web/TableController.java b/server/src/main/java/com/github/jinba1/cuckoodb/server/web/TableController.java index eedd1a4..ccdedaa 100644 --- a/server/src/main/java/com/github/jinba1/cuckoodb/server/web/TableController.java +++ b/server/src/main/java/com/github/jinba1/cuckoodb/server/web/TableController.java @@ -3,8 +3,9 @@ import com.github.jinba1.cuckoodb.CsvFormats; import com.github.jinba1.cuckoodb.QueryExecutionException; import com.github.jinba1.cuckoodb.server.catalog.CatalogFacade; +import com.github.jinba1.cuckoodb.server.catalog.CatalogMapper; +import com.github.jinba1.cuckoodb.server.catalog.TableNameValidator; import com.github.jinba1.cuckoodb.server.config.CuckooDbProperties; -import com.github.jinba1.cuckoodb.server.web.dto.TableColumnDto; import com.github.jinba1.cuckoodb.server.web.dto.TableSchemaResponse; import com.github.jinba1.cuckoodb.server.web.dto.UploadResponse; @@ -27,10 +28,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.StandardOpenOption; -import java.util.ArrayList; import java.util.List; import java.util.UUID; -import java.util.regex.Pattern; /** * Table catalog endpoints: list tables, describe a table's static typed schema, and (opt-in) @@ -43,9 +42,6 @@ @RequestMapping("/tables") public class TableController { - /** The only table names the server will touch; blocks path traversal and odd characters. */ - private static final Pattern VALID_NAME = Pattern.compile("^[A-Za-z0-9_]{1,64}$"); - private final CatalogFacade catalog; private final CuckooDbProperties properties; @@ -63,10 +59,10 @@ public List list() { /** {@code GET /tables/{name}} — a table's static, catalog-authoritative typed schema. */ @GetMapping("/{name}") public TableSchemaResponse describe(@PathVariable String name) { - validateName(name); + TableNameValidator.validate(name); List columns = catalog.columnsOf(name) .orElseThrow(() -> new TableNotFoundException(name)); - return new TableSchemaResponse(name, toDto(columns)); + return new TableSchemaResponse(name, CatalogMapper.toDto(columns)); } /** @@ -83,7 +79,7 @@ public UploadResponse upload(@PathVariable String name, HttpServletRequest reque if (!properties.upload().enabled()) { throw new UploadDisabledException(); } - validateName(name); + TableNameValidator.validate(name); int maxTables = properties.upload().maxTables(); // Cheap pre-check to reject an over-cap upload before streaming its body; the authoritative // check is re-done atomically with the register below, so this is only an optimisation. @@ -121,7 +117,7 @@ public UploadResponse upload(@PathVariable String name, HttpServletRequest reque keep = true; List columns = catalog.columnsOf(name).orElseThrow( () -> new IllegalStateException("Table '" + name + "' vanished after register")); - return new UploadResponse(name, toDto(columns), rowCount); + return new UploadResponse(name, CatalogMapper.toDto(columns), rowCount); } finally { if (!keep) { Files.deleteIfExists(target); @@ -129,13 +125,6 @@ public UploadResponse upload(@PathVariable String name, HttpServletRequest reque } } - private void validateName(String name) { - if (name == null || !VALID_NAME.matcher(name).matches()) { - throw new IllegalArgumentException( - "Invalid table name '" + name + "'; must match [A-Za-z0-9_]{1,64}."); - } - } - /** Builds a work-dir-relative target and proves it cannot escape that directory. */ private Path resolveSafeTarget(String name) { Path base = Path.of(properties.workDir()).toAbsolutePath().normalize(); @@ -188,12 +177,4 @@ private static String sanitize(String message, Path target) { // so target.toString() is the exact path the engine embeds in its messages. return message.replace(target.toString(), ""); } - - private static List toDto(List columns) { - List dtos = new ArrayList<>(columns.size()); - for (CatalogFacade.TableColumn c : columns) { - dtos.add(new TableColumnDto(c.name(), c.type() == null ? null : c.type().name())); - } - return dtos; - } } diff --git a/server/src/main/resources/application.properties b/server/src/main/resources/application.properties index f93ed16..956f5c1 100644 --- a/server/src/main/resources/application.properties +++ b/server/src/main/resources/application.properties @@ -34,3 +34,15 @@ server.tomcat.connection-timeout=35s # OpenAPI / Swagger UI springdoc.swagger-ui.path=/swagger-ui.html + +# --- MCP server (agent tool surface over the query gateway) --- +# Streamable HTTP transport, SYNC tools, served at /mcp. These are mostly the Spring AI 2.x +# defaults, set explicitly to document the contract and pin it against future default changes. +# SSE is deliberately NOT used: it is deprecated in 2.0 and had a 0-bytes bug on Boot 4 / Tomcat 11. +# The @McpTool methods on CuckooMcpTools are auto-registered by the annotation scanner (default on); +# no ToolCallbackProvider bean is declared (that is the legacy @Tool path and would fail startup). +spring.ai.mcp.server.protocol=STREAMABLE +spring.ai.mcp.server.type=SYNC +spring.ai.mcp.server.name=cuckoodb +spring.ai.mcp.server.version=1.0.0 +spring.ai.mcp.server.streamable-http.mcp-endpoint=/mcp diff --git a/server/src/test/java/com/github/jinba1/cuckoodb/server/catalog/CatalogMapperTest.java b/server/src/test/java/com/github/jinba1/cuckoodb/server/catalog/CatalogMapperTest.java new file mode 100644 index 0000000..6e9d872 --- /dev/null +++ b/server/src/test/java/com/github/jinba1/cuckoodb/server/catalog/CatalogMapperTest.java @@ -0,0 +1,47 @@ +package com.github.jinba1.cuckoodb.server.catalog; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.github.jinba1.cuckoodb.ColumnType; +import com.github.jinba1.cuckoodb.server.web.dto.TableColumnDto; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +/** + * The shared catalog-column → DTO mapping, used by the REST describe endpoint and the MCP + * {@code describe_table} tool so both render an identical schema shape. The type name is the enum + * name ({@code INT} / {@code STRING}); a null catalog type maps to a null type string (an + * empty-result column has no inferred type). + */ +class CatalogMapperTest { + + @Test + void mapsIntAndStringTypesInColumnOrder() { + List dtos = CatalogMapper.toDto(List.of( + new CatalogFacade.TableColumn("id", ColumnType.INT), + new CatalogFacade.TableColumn("name", ColumnType.STRING))); + + assertEquals(2, dtos.size()); + assertEquals("id", dtos.get(0).name()); + assertEquals("INT", dtos.get(0).type()); + assertEquals("name", dtos.get(1).name()); + assertEquals("STRING", dtos.get(1).type()); + } + + @Test + void nullColumnTypeMapsToNullTypeString() { + List dtos = CatalogMapper.toDto(List.of( + new CatalogFacade.TableColumn("mystery", null))); + + assertEquals("mystery", dtos.get(0).name()); + assertNull(dtos.get(0).type()); + } + + @Test + void emptyColumnsMapToEmptyList() { + assertEquals(List.of(), CatalogMapper.toDto(List.of())); + } +} diff --git a/server/src/test/java/com/github/jinba1/cuckoodb/server/catalog/TableNameValidatorTest.java b/server/src/test/java/com/github/jinba1/cuckoodb/server/catalog/TableNameValidatorTest.java new file mode 100644 index 0000000..7cbbf9f --- /dev/null +++ b/server/src/test/java/com/github/jinba1/cuckoodb/server/catalog/TableNameValidatorTest.java @@ -0,0 +1,54 @@ +package com.github.jinba1.cuckoodb.server.catalog; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; + +/** + * The shared table-name guard, used by the REST upload path and the MCP tools so both enforce one + * charset. Mirrors {@code ^[A-Za-z0-9_]{1,64}$}: it blocks path-traversal shapes (dot, slash) and + * odd characters before any name reaches the engine or a filesystem path. + */ +class TableNameValidatorTest { + + @Test + void acceptsAlphanumericAndUnderscore() { + assertDoesNotThrow(() -> TableNameValidator.validate("Student")); + assertDoesNotThrow(() -> TableNameValidator.validate("student_2024")); + assertDoesNotThrow(() -> TableNameValidator.validate("ABC_123_xyz")); + } + + @Test + void acceptsSingleCharAndMaxLengthBoundaries() { + assertDoesNotThrow(() -> TableNameValidator.validate("a")); + assertDoesNotThrow(() -> TableNameValidator.validate("_")); + assertDoesNotThrow(() -> TableNameValidator.validate("a".repeat(64))); + } + + @Test + void rejectsNull() { + assertThrows(IllegalArgumentException.class, () -> TableNameValidator.validate(null)); + } + + @Test + void rejectsEmpty() { + assertThrows(IllegalArgumentException.class, () -> TableNameValidator.validate("")); + } + + @Test + void rejectsOverMaxLength() { + assertThrows(IllegalArgumentException.class, + () -> TableNameValidator.validate("a".repeat(65))); + } + + @Test + void rejectsPathTraversalAndOddCharacters() { + // Dot and slash are the path-traversal shapes; space/dash/semicolon are SQL-injection bait. + for (String bad : new String[] {"a.b", "../etc", "a/b", "a b", "a-b", "a;b", "DROP TABLE", + "tab\tname", "naïve"}) { + assertThrows(IllegalArgumentException.class, () -> TableNameValidator.validate(bad), + "should reject: " + bad); + } + } +} diff --git a/server/src/test/java/com/github/jinba1/cuckoodb/server/mcp/CuckooMcpIntegrationTest.java b/server/src/test/java/com/github/jinba1/cuckoodb/server/mcp/CuckooMcpIntegrationTest.java new file mode 100644 index 0000000..f8c0735 --- /dev/null +++ b/server/src/test/java/com/github/jinba1/cuckoodb/server/mcp/CuckooMcpIntegrationTest.java @@ -0,0 +1,180 @@ +package com.github.jinba1.cuckoodb.server.mcp; + +import static com.github.jinba1.cuckoodb.server.TestFiles.deleteRecursively; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.modelcontextprotocol.client.McpClient; +import io.modelcontextprotocol.client.McpSyncClient; +import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport; +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.CallToolRequest; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Duration; +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; + +/** + * End-to-end proof of the MCP tool layer over a real Spring context and a temp-dir catalog. Two + * halves: (1) the autowired {@link CuckooMcpTools} is driven directly to prove choke-point routing + * — real {@code QueryService} (so the budget clamp bites) and real {@code CatalogFacade}; (2) one + * wire test drives the live {@code /mcp} Streamable-HTTP endpoint with the MCP SDK client to prove + * the bare {@code @Component} + {@code @McpTool} methods register with no provider bean and round-trip. + * + *

Catalog isolation is by fresh context ({@code @DirtiesContext}), never {@code resetDBCatalog}: + * the engine singleton is owned by the app's {@code CatalogInitializer}, and a stray reset would + * wipe the catalog mid-context and break sibling tests in the same Surefire run. + */ +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class CuckooMcpIntegrationTest { + + private static final ObjectMapper JSON = new ObjectMapper(); + + @Autowired + private CuckooMcpTools tools; + + @LocalServerPort + private int port; + + private static Path dataDir; + private static Path workDir; + + @DynamicPropertySource + static void properties(DynamicPropertyRegistry registry) throws IOException { + dataDir = Files.createTempDirectory("cuckoo-mcp-data"); + Path data = Files.createDirectories(dataDir.resolve("data")); + Files.writeString(data.resolve("People.csv"), "id,name\n1,alice\n2,bob\n3,carol\n"); + workDir = Files.createTempDirectory("cuckoo-mcp-work"); + + registry.add("cuckoodb.data-dir", () -> dataDir.toString()); + registry.add("cuckoodb.work-dir", () -> workDir.toString()); + registry.add("cuckoodb.upload.enabled", () -> "false"); + } + + @AfterAll + static void cleanup() throws IOException { + deleteRecursively(dataDir); + deleteRecursively(workDir); + } + + private static boolean isError(CallToolResult r) { + return Boolean.TRUE.equals(r.isError()); + } + + private static String text(CallToolResult r) { + return ((McpSchema.TextContent) r.content().get(0)).text(); + } + + private JsonNode body(CallToolResult r) { + return JSON.readTree(text(r)); + } + + // ---- (1) direct-call choke-point proofs ---- + + @Test + void listTablesSeesTheSeededCatalog() { + JsonNode arr = body(tools.listTables()); + assertTrue(arr.isArray()); + assertEquals("People", arr.get(0).asString()); + } + + @Test + void describeTableReturnsCatalogAuthoritativeTypes() { + JsonNode b = body(tools.describeTable("People")); + assertEquals("People", b.get("name").asString()); + assertEquals("id", b.get("columns").get(0).get("name").asString()); + assertEquals("INT", b.get("columns").get(0).get("type").asString()); + assertEquals("STRING", b.get("columns").get(1).get("type").asString()); + } + + @Test + void queryExecutesRealSqlReturningTypedRows() { + JsonNode b = body(tools.query("SELECT * FROM People", null, null)); + assertEquals(3, b.get("rowCount").asInt()); + assertEquals(1, b.get("rows").get(0).get(0).asInt()); + assertEquals("alice", b.get("rows").get(0).get(1).asString()); + } + + @Test + void sampleRowsPreviewsAndMarksTruncation() { + JsonNode b = body(tools.sampleRows("People", 2)); + assertEquals(2, b.get("rowCount").asInt()); + assertTrue(b.get("truncated").asBoolean(), "a 2-row preview of a 3-row table is truncated"); + } + + @Test + void explainQueryReturnsAPlanAndExecutesNothing() { + JsonNode b = body(tools.explainQuery("SELECT * FROM People")); + assertTrue(b.get("explain").asString().contains("Plan")); + assertFalse(b.has("rows"), "EXPLAIN performs no execution"); + } + + @Test + void tinyPerCallBudgetTripsBudgetExceededThroughTheChokePoint() { + // maxTuples=1 on a 3-row scan must trip the budget — proving MCP query inherits the exact + // BudgetPolicy enforcement the REST path gets, with no MCP-specific budget code. + CallToolResult r = tools.query("SELECT * FROM People", 1L, null); + assertTrue(isError(r)); + assertTrue(text(r).startsWith("BUDGET_EXCEEDED"), text(r)); + } + + @Test + void unknownTableMapsToUnknownTable() { + assertTrue(text(tools.describeTable("Nope")).startsWith("UNKNOWN_TABLE:")); + assertTrue(text(tools.query("SELECT * FROM Nope", null, null)).startsWith("UNKNOWN_TABLE:")); + } + + // ---- (2) live /mcp wire test ---- + + @Test + void mcpEndpointRegistersAllToolsAndRoundTrips() { + HttpClientStreamableHttpTransport transport = HttpClientStreamableHttpTransport + .builder("http://localhost:" + port) + .endpoint("/mcp") + .build(); + try (McpSyncClient client = McpClient.sync(transport) + .requestTimeout(Duration.ofSeconds(20)) + .initializationTimeout(Duration.ofSeconds(20)) + .build()) { + client.initialize(); + + // Registration: the bare @Component's five @McpTool methods are all discovered by the + // annotation scanner — no MethodToolCallbackProvider/ToolCallbackProvider bean exists. + List toolNames = client.listTools().tools().stream() + .map(McpSchema.Tool::name).toList(); + assertTrue(toolNames.containsAll(List.of( + "list_tables", "describe_table", "sample_rows", "explain_query", "query")), + "registered tools: " + toolNames); + + // Success path over the wire. + CallToolResult listed = client.callTool( + CallToolRequest.builder("list_tables").arguments(Map.of()).build()); + assertFalse(isError(listed)); + assertTrue(text(listed).contains("People"), text(listed)); + + // Error path over the wire: a bad SQL surfaces as an MCP tool error, not a transport fault. + CallToolResult bad = client.callTool( + CallToolRequest.builder("query").arguments(Map.of("sql", "SELECT FROM")).build()); + assertTrue(isError(bad)); + assertTrue(text(bad).startsWith("PARSE_ERROR:"), text(bad)); + } + } +} diff --git a/server/src/test/java/com/github/jinba1/cuckoodb/server/mcp/CuckooMcpToolsTest.java b/server/src/test/java/com/github/jinba1/cuckoodb/server/mcp/CuckooMcpToolsTest.java new file mode 100644 index 0000000..95286b7 --- /dev/null +++ b/server/src/test/java/com/github/jinba1/cuckoodb/server/mcp/CuckooMcpToolsTest.java @@ -0,0 +1,345 @@ +package com.github.jinba1.cuckoodb.server.mcp; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.github.jinba1.cuckoodb.BudgetKind; +import com.github.jinba1.cuckoodb.ColumnMeta; +import com.github.jinba1.cuckoodb.ColumnType; +import com.github.jinba1.cuckoodb.ErrorCode; +import com.github.jinba1.cuckoodb.IntValue; +import com.github.jinba1.cuckoodb.QueryBudgetExceededException; +import com.github.jinba1.cuckoodb.QueryExecutionException; +import com.github.jinba1.cuckoodb.QueryResultSet; +import com.github.jinba1.cuckoodb.StringValue; +import com.github.jinba1.cuckoodb.Value; +import com.github.jinba1.cuckoodb.server.catalog.CatalogFacade; +import com.github.jinba1.cuckoodb.server.query.ConcurrencyLimitExceededException; +import com.github.jinba1.cuckoodb.server.query.QueryService; +import com.github.jinba1.cuckoodb.server.query.QueryServiceResult; + +import io.modelcontextprotocol.spec.McpSchema; +import io.modelcontextprotocol.spec.McpSchema.CallToolResult; + +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +/** + * Unit-level contract for the five MCP tools, with a mocked {@link QueryService} and + * {@link CatalogFacade} and a real Jackson mapper. Proves the choke-point routing (every query + * tool calls {@code QueryService.execute(..., "mcp")} and never the engine), the injection guard + * (a bad table name is rejected before any SQL is built), the exact SQL/budget the catalog tools + * synthesize, and the error taxonomy mapped onto {@code CallToolResult.isError(true)} text — + * including that a 5xx-class failure is scrubbed of filesystem paths. + */ +class CuckooMcpToolsTest { + + private static final ObjectMapper JSON = new ObjectMapper(); + + private QueryService queryService; + private CatalogFacade catalog; + private CuckooMcpTools tools; + + @BeforeEach + void setUp() { + queryService = Mockito.mock(QueryService.class); + catalog = Mockito.mock(CatalogFacade.class); + tools = new CuckooMcpTools(queryService, catalog, JSON); + } + + private static boolean isError(CallToolResult r) { + return Boolean.TRUE.equals(r.isError()); + } + + private static String text(CallToolResult r) { + return ((McpSchema.TextContent) r.content().get(0)).text(); + } + + private JsonNode body(CallToolResult r) { + return JSON.readTree(text(r)); + } + + private static QueryResultSet oneRow() { + return new QueryResultSet( + List.of(new ColumnMeta("a", "student.a", ColumnType.INT), + new ColumnMeta("name", "student.name", ColumnType.STRING)), + List.of(List.of(new IntValue(1), new StringValue("alice"))), + false, null); + } + + // ---- list_tables ---- + + @Test + void listTablesReturnsSortedArray() { + when(catalog.tableNames()).thenReturn(List.of("Alpha", "Beta", "Gamma")); + + CallToolResult r = tools.listTables(); + + assertFalse(isError(r)); + JsonNode arr = body(r); + assertTrue(arr.isArray()); + assertEquals(3, arr.size()); + assertEquals("Alpha", arr.get(0).asString()); + assertEquals("Gamma", arr.get(2).asString()); + verifyNoInteractions(queryService); + } + + @Test + void listTablesEmptyReturnsEmptyArray() { + when(catalog.tableNames()).thenReturn(List.of()); + CallToolResult r = tools.listTables(); + assertFalse(isError(r)); + assertEquals(0, body(r).size()); + } + + // ---- describe_table ---- + + @Test + void describeTableReturnsCatalogSchema() { + when(catalog.columnsOf("Student")).thenReturn(Optional.of(List.of( + new CatalogFacade.TableColumn("a", ColumnType.INT), + new CatalogFacade.TableColumn("name", ColumnType.STRING)))); + + CallToolResult r = tools.describeTable("Student"); + + assertFalse(isError(r)); + JsonNode b = body(r); + assertEquals("Student", b.get("name").asString()); + assertEquals("a", b.get("columns").get(0).get("name").asString()); + assertEquals("INT", b.get("columns").get(0).get("type").asString()); + assertEquals("STRING", b.get("columns").get(1).get("type").asString()); + verifyNoInteractions(queryService); + } + + @Test + void describeTableUnknownMapsToUnknownTable() { + when(catalog.columnsOf("Nope")).thenReturn(Optional.empty()); + + CallToolResult r = tools.describeTable("Nope"); + + assertTrue(isError(r)); + assertTrue(text(r).startsWith("UNKNOWN_TABLE:"), text(r)); + verify(catalog).columnsOf("Nope"); + } + + @Test + void describeTableInvalidNameMapsToBadRequestAndNeverHitsCatalog() { + CallToolResult r = tools.describeTable("a.b"); + + assertTrue(isError(r)); + assertTrue(text(r).startsWith("BAD_REQUEST:"), text(r)); + verify(catalog, never()).columnsOf(any()); + verifyNoInteractions(queryService); + } + + // ---- sample_rows ---- + + @Test + void sampleRowsBuildsExactSqlBoundedByTheLimit() { + // The LIMIT bounds the preview; the lazy engine pulls only ~n+1 tuples, so the tool passes + // no explicit tuple budget (null = server default) rather than one that would trip on the + // truncation-detecting pull. + when(queryService.execute(eq("SELECT * FROM People LIMIT 5"), isNull(), isNull(), eq("mcp"))) + .thenReturn(QueryServiceResult.of(oneRow())); + + CallToolResult r = tools.sampleRows("People", 5); + + assertFalse(isError(r)); + assertEquals(1, body(r).get("rowCount").asInt()); + verify(queryService).execute("SELECT * FROM People LIMIT 5", null, null, "mcp"); + } + + @Test + void sampleRowsDefaultsLimitToTenWhenNull() { + when(queryService.execute(eq("SELECT * FROM People LIMIT 10"), isNull(), isNull(), eq("mcp"))) + .thenReturn(QueryServiceResult.of(oneRow())); + + tools.sampleRows("People", null); + + verify(queryService).execute("SELECT * FROM People LIMIT 10", null, null, "mcp"); + } + + @Test + void sampleRowsClampsLimitToOneThousandUpperBound() { + when(queryService.execute(eq("SELECT * FROM People LIMIT 1000"), isNull(), isNull(), eq("mcp"))) + .thenReturn(QueryServiceResult.of(oneRow())); + + tools.sampleRows("People", 999_999); + + verify(queryService).execute("SELECT * FROM People LIMIT 1000", null, null, "mcp"); + } + + @Test + void sampleRowsClampsNonPositiveLimitToOne() { + when(queryService.execute(eq("SELECT * FROM People LIMIT 1"), isNull(), isNull(), eq("mcp"))) + .thenReturn(QueryServiceResult.of(oneRow())); + + tools.sampleRows("People", 0); + + verify(queryService).execute("SELECT * FROM People LIMIT 1", null, null, "mcp"); + } + + @Test + void sampleRowsRejectsInjectionBeforeAnyQuery() { + // A semicolon name is blocked by the charset BEFORE it is ever interpolated into SQL. + CallToolResult r = tools.sampleRows("foo;DROP", 10); + + assertTrue(isError(r)); + assertTrue(text(r).startsWith("BAD_REQUEST:"), text(r)); + verifyNoInteractions(queryService); + } + + // ---- explain_query ---- + + @Test + void explainQueryPrependsExplainAndReturnsPlanText() { + when(queryService.execute(eq("EXPLAIN SELECT * FROM People"), isNull(), isNull(), eq("mcp"))) + .thenReturn(QueryServiceResult.explain("=== Plan (as written) ===\nScan[People]")); + + CallToolResult r = tools.explainQuery("SELECT * FROM People"); + + assertFalse(isError(r)); + assertTrue(body(r).get("explain").asString().contains("Plan")); + verify(queryService).execute("EXPLAIN SELECT * FROM People", null, null, "mcp"); + } + + // ---- query ---- + + @Test + void querySerializesTypedColumnArrays() { + when(queryService.execute(eq("SELECT * FROM Student"), isNull(), isNull(), eq("mcp"))) + .thenReturn(QueryServiceResult.of(oneRow())); + + CallToolResult r = tools.query("SELECT * FROM Student", null, null); + + assertFalse(isError(r)); + JsonNode b = body(r); + assertEquals(1, b.get("rowCount").asInt()); + assertEquals(false, b.get("truncated").asBoolean()); + assertEquals("a", b.get("columns").get(0).get("name").asString()); + assertEquals(1, b.get("rows").get(0).get(0).asInt()); + assertEquals("alice", b.get("rows").get(0).get(1).asString()); + } + + @Test + void queryPassesClientBudgetThroughToTheChokePoint() { + when(queryService.execute(eq("SELECT * FROM Student"), eq(50L), eq(2000L), eq("mcp"))) + .thenReturn(QueryServiceResult.of(oneRow())); + + tools.query("SELECT * FROM Student", 50L, 2000L); + + verify(queryService).execute("SELECT * FROM Student", 50L, 2000L, "mcp"); + } + + @Test + void queryReturnsPlanWhenCallerPrefixedExplain() { + // Defensive: a caller who routes EXPLAIN through `query` still gets the plan shape back. + when(queryService.execute(any(), any(), any(), eq("mcp"))) + .thenReturn(QueryServiceResult.explain("=== Plan (as written) ===\nScan[Student]")); + + CallToolResult r = tools.query("EXPLAIN SELECT * FROM Student", null, null); + + assertFalse(isError(r)); + assertTrue(body(r).get("explain").asString().contains("Plan")); + } + + // ---- error taxonomy ---- + + @Test + void tupleBudgetMapsToBudgetExceeded() { + when(queryService.execute(any(), any(), any(), any())) + .thenThrow(new QueryBudgetExceededException(BudgetKind.TUPLES, "Tuple budget exceeded")); + + CallToolResult r = tools.query("SELECT * FROM Big", null, null); + + assertTrue(isError(r)); + assertTrue(text(r).startsWith("BUDGET_EXCEEDED:"), text(r)); + } + + @Test + void timeBudgetMapsToBudgetExceededTimeout() { + when(queryService.execute(any(), any(), any(), any())) + .thenThrow(new QueryBudgetExceededException(BudgetKind.TIME, "Time budget exceeded")); + + CallToolResult r = tools.query("SELECT * FROM Slow", null, null); + + assertTrue(isError(r)); + assertTrue(text(r).startsWith("BUDGET_EXCEEDED (timeout):"), text(r)); + } + + @Test + void unknownTableOnQueryPathIsVerbatim() { + when(queryService.execute(any(), any(), any(), any())).thenThrow( + new QueryExecutionException(ErrorCode.UNKNOWN_TABLE, + "Table 'Nope' not found. Available tables: Student.")); + + CallToolResult r = tools.query("SELECT * FROM Nope", null, null); + + assertTrue(isError(r)); + assertTrue(text(r).startsWith("UNKNOWN_TABLE:"), text(r)); + assertTrue(text(r).contains("Available tables"), text(r)); + } + + @Test + void parseErrorIsVerbatim() { + when(queryService.execute(any(), any(), any(), any())) + .thenThrow(new QueryExecutionException(ErrorCode.PARSE_ERROR, "SQL syntax error: bad")); + + CallToolResult r = tools.query("SELECT FROM", null, null); + + assertTrue(isError(r)); + assertTrue(text(r).startsWith("PARSE_ERROR:"), text(r)); + } + + @Test + void dataErrorIsScrubbedOfFilesystemPaths() { + when(queryService.execute(any(), any(), any(), any())).thenThrow( + new QueryExecutionException(ErrorCode.DATA_ERROR, + "Failed to open table 'x': /var/secret/path/data.csv broken")); + + CallToolResult r = tools.query("SELECT * FROM X", null, null); + + assertTrue(isError(r)); + assertTrue(text(r).startsWith("INTERNAL:"), text(r)); + assertTrue(text(r).contains("errorId="), text(r)); + assertFalse(text(r).contains("/var/secret"), "data-error text leaked a filesystem path: " + text(r)); + } + + @Test + void internalErrorIsScrubbed() { + when(queryService.execute(any(), any(), any(), any())) + .thenThrow(new QueryExecutionException(ErrorCode.INTERNAL, "invariant broke at /home/x")); + + CallToolResult r = tools.query("SELECT * FROM X", null, null); + + assertTrue(isError(r)); + assertTrue(text(r).startsWith("INTERNAL:"), text(r)); + assertFalse(text(r).contains("/home/x"), text(r)); + } + + @Test + void concurrencyLimitMaps() { + when(queryService.execute(any(), any(), any(), any())) + .thenThrow(new ConcurrencyLimitExceededException("at limit")); + + CallToolResult r = tools.query("SELECT * FROM Student", null, null); + + assertTrue(isError(r)); + assertTrue(text(r).startsWith("CONCURRENCY_LIMIT:"), text(r)); + } +}