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));
+ }
+}