Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,9 +245,8 @@ their native runners. See `desktop/README.md` for the full picture.
## MCP Server

- `mcp/deepsql-phase1-server.js` implements a Phase 1 stdio MCP server for internal rollout.
- It exposes read-only tools only: listing connections, fetching schema/objects, asking DeepSQL questions, executing read-only SQL, and running EXPLAIN without ANALYZE.
- It wraps existing backend APIs, so it reuses DeepSQL chat orchestration, RAG, connection management, and guardrails instead of exposing raw DB credentials.
- Read-only enforcement is applied in `mcp/deepsql-phase1-lib.js` before calling backend execution endpoints.
- Schema/retrieval tools stay read-only. `execute_sql` is role-gated: developers stay read-only; admins can run DML and non-destructive DDL (`CREATE`, `ALTER`) with the same two-step confirmation as the SQL Editor. `DROP` and `TRUNCATE` stay blocked on MCP even when confirmed.
- It wraps existing backend APIs, so it reuses DeepSQL chat orchestration, RAG, connection management, and `QueryExecutionPolicyService` instead of exposing raw DB credentials.
- Client config examples live in `.cursor/mcp.json` and `mcp/claude_desktop_config.example.json`.
- Usage and env vars are documented in `docs/root/MCP_PHASE1.md`.

Expand Down
2 changes: 1 addition & 1 deletion agent/SOUL.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ After the answer you may offer **one short follow-up question** (a single line)

4. **Table-qualify every column** in generated SQL (`table.column`). Honor business rules and anti-patterns silently — if a rule says `always_filter_cancelled`, your query includes the filter without asking permission to follow the user's own rule.

5. **Read-only by default.** Developers cannot mutate; admins can with a **two-step confirmation**. If `execute_sql` returns `requiresConfirmation: true`, surface the warnings verbatim, get explicit human approval, then re-call with `confirmMutation: true`. NEVER auto-confirm — that defeats the safety gate. Never try to work around a 403/`EDITOR_MUTATION_FORBIDDEN`; surface it.
5. **Read-only by default.** Developers cannot mutate; admins can run DML and non-destructive DDL (`CREATE`, `ALTER`) with a **two-step confirmation**. `DROP` and `TRUNCATE` cannot be run via `execute_sql` — they stay blocked even after confirmation. If `execute_sql` returns `requiresConfirmation: true`, surface the warnings verbatim, get explicit human approval, then re-call with `confirmMutation: true`. NEVER auto-confirm — that defeats the safety gate. Never try to work around a 403/`EDITOR_MUTATION_FORBIDDEN` or an `UNSAFE_MUTATION_BLOCKED` DROP/TRUNCATE; surface it.

6. **One execution tool, one analysis tool.** Use `execute_sql` to run SQL; use `analyze_query_plan` for plans. Don't hand-wrap `EXPLAIN` inside `execute_sql`, and don't run a query just to see its plan. `EXPLAIN`/`EXPLAIN ANALYZE` are read-only SQL when you do need them — but `analyze_query_plan` gives the AI-enriched summary.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import com.dbaagent.service.ClientContext;
import com.dbaagent.service.CredentialService;
import com.dbaagent.service.ExplainPlanService;
import com.dbaagent.service.McpTokenService;
import com.dbaagent.service.QueryExecutionContext;
import com.dbaagent.service.QueryExecutionPolicyException;
import com.dbaagent.service.QueryExecutionPolicyService;
Expand All @@ -19,6 +20,7 @@
import lombok.Data;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.BadSqlGrammarException;
Expand All @@ -38,9 +40,10 @@
* 1. `useAnalyze=true` actually runs the underlying statement inside
* EXPLAIN ANALYZE — so for any mutating statement, that's a real
* database write. We route those through QueryExecutionPolicyService
* with `QueryExecutionContext.editor(...)` so the same role/WHERE/
* confirmation gates that protect /api/connections/{id}/query
* protect this path too.
* with `QueryExecutionContext.forSqlSurface(...)` so MCP bearers
* keep the MCP DROP/TRUNCATE block and Editor JWT callers keep the
* Editor DROP TABLE block. Role, WHERE, and confirmation gates that
* protect /api/connections/{id}/query protect this path too.
*
* 2. Every call — success, blocked, or failed — emits a SecurityEvent so
* audit dashboards can see CLI/MCP/Editor traffic with one filter.
Expand Down Expand Up @@ -71,23 +74,27 @@ public ResponseEntity<?> analyzeQuery(
) {
ClientContext client = ClientContext.fromRequest(httpRequest);
String connectionId = request.getConnectionId();
QueryRequest auditQueryRequest = buildAuditQueryRequest(request);
QueryRequest auditQueryRequest = buildAuditQueryRequest(request, httpRequest);
ConnectionRequest connectionRequest = null;

try {
accessControlService.assertCanUseChatEditor(connectionId);
log.info("EXPLAIN analysis requested for connection: {} (useAnalyze={})", connectionId, request.isUseAnalyze());

// ANALYZE actually executes the SQL. Route the underlying
// statement through the same policy gate the SQL Editor uses so
// a developer can't bypass the mutation guard by sending
// useAnalyze=true with `DELETE FROM users`.
// statement through the same policy gate /connections/{id}/query
// uses so a developer can't bypass the mutation guard by sending
// useAnalyze=true with `DELETE FROM users`, and MCP callers keep
// the DROP/TRUNCATE block.
if (request.isUseAnalyze()) {
connectionRequest = credentialService.getDecryptedConnection(connectionId);
String dbType = providerRegistry.getCanonicalName(connectionRequest.getDbType());
queryExecutionPolicyService.enforce(
auditQueryRequest,
QueryExecutionContext.editor(
QueryExecutionContext.forSqlSurface(
McpTokenService.isMcpAuthorizationHeader(
httpRequest.getHeader(HttpHeaders.AUTHORIZATION)
),
accessControlService.getCurrentUsername(),
accessControlService.isCurrentUserAdmin(),
Boolean.TRUE.equals(request.getMutationConfirmed())
Expand Down Expand Up @@ -161,10 +168,13 @@ public ResponseEntity<?> analyzeQuery(
* Carries the user's mutationConfirmed flag through so admin-confirmed
* ANALYZE runs aren't stuck on the confirmation gate.
*/
private QueryRequest buildAuditQueryRequest(ExplainRequest request) {
private QueryRequest buildAuditQueryRequest(ExplainRequest request, HttpServletRequest httpRequest) {
QueryRequest qr = new QueryRequest();
qr.setQuery(request.getQuery());
qr.setExecutionOrigin(QueryExecutionOrigin.EDITOR);
boolean mcpBearer = McpTokenService.isMcpAuthorizationHeader(
httpRequest.getHeader(HttpHeaders.AUTHORIZATION)
);
qr.setExecutionOrigin(mcpBearer ? QueryExecutionOrigin.MCP : QueryExecutionOrigin.EDITOR);
qr.setMutationConfirmed(Boolean.TRUE.equals(request.getMutationConfirmed()));
return qr;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -191,7 +191,12 @@ public ResponseEntity<Map<String, Object>> executeQuery(
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
}
accessControlService.assertCanUseChatEditor(connectionId);
queryRequest.setExecutionOrigin(QueryExecutionOrigin.EDITOR);
boolean mcpBearer = McpTokenService.isMcpAuthorizationHeader(
httpRequest.getHeader(HttpHeaders.AUTHORIZATION)
);
queryRequest.setExecutionOrigin(
mcpBearer ? QueryExecutionOrigin.MCP : QueryExecutionOrigin.EDITOR
);
connectionRequest = credentialService.getDecryptedConnection(connectionId);

QueryResult result = queryExecutorService.executeQuery(
Expand Down Expand Up @@ -438,25 +443,14 @@ public ResponseEntity<Map<String, Object>> getTableStats(
}

private QueryExecutionContext queryExecutionContext(QueryRequest queryRequest, HttpServletRequest httpRequest) {
String username = accessControlService.getCurrentUsername();
boolean admin = accessControlService.isCurrentUserAdmin();
if (isMcpBearer(httpRequest)) {
return QueryExecutionContext.mcp(username, admin);
}
return QueryExecutionContext.editor(
username,
admin,
return QueryExecutionContext.forSqlSurface(
McpTokenService.isMcpAuthorizationHeader(httpRequest.getHeader(HttpHeaders.AUTHORIZATION)),
accessControlService.getCurrentUsername(),
accessControlService.isCurrentUserAdmin(),
Boolean.TRUE.equals(queryRequest.getMutationConfirmed())
);
}

private boolean isMcpBearer(HttpServletRequest httpRequest) {
String authorization = httpRequest.getHeader(HttpHeaders.AUTHORIZATION);
return authorization != null
&& authorization.startsWith("Bearer ")
&& authorization.substring(7).startsWith(McpTokenService.TOKEN_PREFIX);
}

private SchemaMetadata scopedSchema(String connectionId, SchemaMetadata schema) {
return userDataAccessPolicyService.filterSchemaMetadata(
connectionId,
Expand Down
11 changes: 11 additions & 0 deletions backend/src/main/java/com/dbaagent/service/McpTokenService.java
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,17 @@ public boolean looksLikeMcpToken(String rawToken) {
return rawToken != null && rawToken.startsWith(TOKEN_PREFIX);
}

/**
* True when the Authorization header is a DeepSQL MCP bearer token
* ({@code Bearer dsql_mcp_…}). JWT and other Bearer schemes return false.
*/
public static boolean isMcpAuthorizationHeader(String authorization) {
if (authorization == null || !authorization.regionMatches(true, 0, "Bearer ", 0, 7)) {
return false;
}
return authorization.substring(7).startsWith(TOKEN_PREFIX);
}

private boolean isExpired(McpToken token) {
return token.getExpiresAt() != null && !token.getExpiresAt().isAfter(LocalDateTime.now());
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,15 +52,40 @@ public static QueryExecutionContext mcp(String actorUsername) {
}

public static QueryExecutionContext mcp(String actorUsername, boolean actorIsAdmin) {
return mcp(actorUsername, actorIsAdmin, false);
}

/**
* MCP / coding-agent SQL. Developers stay read-only. Admins may run
* non-destructive DDL/DML after the same confirmation gate as the Editor.
* DROP and TRUNCATE stay blocked in {@link QueryExecutionPolicyService}.
*/
public static QueryExecutionContext mcp(
String actorUsername,
boolean actorIsAdmin,
boolean mutationConfirmed
) {
return new QueryExecutionContext(
QueryExecutionOrigin.MCP,
MutationMode.READ_ONLY_ONLY,
actorIsAdmin ? MutationMode.MAY_MUTATE : MutationMode.READ_ONLY_ONLY,
actorUsername,
actorIsAdmin,
false
mutationConfirmed
);
}

public static QueryExecutionContext forSqlSurface(
boolean mcpBearer,
String actorUsername,
boolean actorIsAdmin,
boolean mutationConfirmed
) {
if (mcpBearer) {
return mcp(actorUsername, actorIsAdmin, mutationConfirmed);
}
return editor(actorUsername, actorIsAdmin, mutationConfirmed);
}

public static QueryExecutionContext scheduled() {
return new QueryExecutionContext(
QueryExecutionOrigin.SCHEDULED,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,16 @@ public class QueryExecutionPolicyService {
"^\\s*DROP\\s+(?:IF\\s+EXISTS\\s+)?TABLE\\b",
Pattern.CASE_INSENSITIVE
);
// MCP/coding-agent loops may not run any DROP or TRUNCATE, including
// DROP INDEX / DROP VIEW. EXPLAIN wrappers are stripped before matching.
private static final Pattern DROP_OR_TRUNCATE_HEAD = Pattern.compile(
"^\\s*(DROP|TRUNCATE)\\b",
Pattern.CASE_INSENSITIVE
);
private static final Pattern EXPLAIN_PREFIX_PATTERN = Pattern.compile(
"^\\s*EXPLAIN(?:\\s*\\([^)]*\\)|\\s+ANALYZE)?\\s+",
Pattern.CASE_INSENSITIVE
);

// Text-level backstops for writes that hide inside a statement which reads as
// a SELECT. Applied to SQL with literals and comments already stripped.
Expand Down Expand Up @@ -137,6 +147,15 @@ public PolicyDecision enforce(
throw QueryExecutionPolicyException.editorMutationForbidden(mutation.queryType());
}

if (origin == QueryExecutionOrigin.MCP
&& isDropOrTruncateStatement(mutation.queryType(), statements.getFirst())) {
throw QueryExecutionPolicyException.unsafeMutation(
"DROP and TRUNCATE are blocked on MCP and coding-agent loops. "
+ "CREATE, ALTER, and DML still require admin privileges plus confirmation.",
mutation.queryType()
);
}

if (origin == QueryExecutionOrigin.EDITOR
&& isDropTableStatement(mutation.queryType(), statements.getFirst())) {
throw QueryExecutionPolicyException.unsafeMutation(
Expand Down Expand Up @@ -204,6 +223,15 @@ private StatementClassification classifyStatement(String statement, QueryExecuti
return new StatementClassification("SELECT", true, false, false, false, false);
}
if (parsed instanceof ExplainStatement) {
String wrappedMutation = detectExplainWrappedMutation(trimmed);
if (wrappedMutation != null) {
boolean requiresWhere = "UPDATE".equalsIgnoreCase(wrappedMutation)
|| "DELETE".equalsIgnoreCase(wrappedMutation);
boolean hasWhere = !requiresWhere || containsWhereClause(trimmed);
return new StatementClassification(
wrappedMutation, false, true, requiresWhere, hasWhere, false
);
}
return new StatementClassification("EXPLAIN", true, false, false, false, false);
}
if (parsed instanceof UseStatement) {
Expand Down Expand Up @@ -400,6 +428,23 @@ private boolean isUseStatement(String sql) {
return sql != null && USE_ANY_PATTERN.matcher(stripLeadingComments(sql)).find();
}

/**
* True for any {@code DROP …} or {@code TRUNCATE …}, including EXPLAIN-wrapped forms.
* Used by the MCP origin gate so coding agents cannot confirm around destructive DDL.
*/
private boolean isDropOrTruncateStatement(String queryType, String sql) {
if (queryType != null) {
String upper = queryType.toUpperCase(Locale.ROOT);
if (upper.startsWith("DROP") || upper.startsWith("TRUNCATE")) {
return true;
}
}
String remaining = stripLeadingComments(sql == null ? "" : sql);
remaining = EXPLAIN_PREFIX_PATTERN.matcher(remaining).replaceFirst("");
remaining = stripLeadingComments(remaining);
return DROP_OR_TRUNCATE_HEAD.matcher(remaining).find();
}

/**
* Returns true only for {@code DROP TABLE}. Other DROP variants (INDEX, VIEW, SEQUENCE,
* SCHEMA, FUNCTION, PROCEDURE, …) are permitted as ordinary mutations for confirmed admins.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
import com.dbaagent.service.AnalysisHistoryService;
import com.dbaagent.service.CredentialService;
import com.dbaagent.service.ExplainPlanService;
import com.dbaagent.model.QueryExecutionOrigin;
import com.dbaagent.service.QueryExecutionContext;
import com.dbaagent.service.QueryExecutionPolicyException;
import com.dbaagent.service.QueryExecutionPolicyService;
import com.dbaagent.service.SqlExecutionAuditService;
Expand All @@ -14,8 +16,10 @@
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

Expand All @@ -26,6 +30,7 @@
import static org.mockito.ArgumentMatchers.anyBoolean;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
Expand Down Expand Up @@ -128,6 +133,30 @@ void useAnalyzeTrue_confirmationRequired_propagatesRequiresConfirmation() {
verify(explainPlanService, never()).analyzeQuery(anyString(), anyString(), anyBoolean());
}

@Test
void useAnalyzeTrue_mcpBearer_usesMcpExecutionContext() {
givenConnection("conn-1", "postgres");
lenient().when(httpRequest.getHeader(anyString())).thenReturn(null);
when(httpRequest.getHeader(HttpHeaders.AUTHORIZATION))
.thenReturn("Bearer dsql_mcp_public.secret");
when(accessControlService.getCurrentUsername()).thenReturn("admin");
when(accessControlService.isCurrentUserAdmin()).thenReturn(true);
when(explainPlanService.analyzeQuery(eq("conn-1"), anyString(), eq(true)))
.thenReturn(new ExplainPlanAnalysis());

controller.analyzeQuery(
request("conn-1", "CREATE TABLE t_new (id INT PRIMARY KEY)", true),
httpRequest
);

ArgumentCaptor<QueryExecutionContext> captor = ArgumentCaptor.forClass(QueryExecutionContext.class);
verify(queryExecutionPolicyService).enforce(any(), captor.capture(), eq("postgres"));
assertThat(captor.getValue().origin()).isEqualTo(QueryExecutionOrigin.MCP);
assertThat(captor.getValue().mutationMode())
.isEqualTo(QueryExecutionContext.MutationMode.MAY_MUTATE);
assertThat(captor.getValue().actorIsAdmin()).isTrue();
}

@Test
void useAnalyzeFalse_skipsPolicyGate_butStillAudits() {
// Plain EXPLAIN doesn't execute the query, so we don't need to gate
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,4 +181,13 @@ void listTokensUsesCurrentUserIdentity() {
assertEquals(1, tokens.size());
assertEquals(1L, tokens.get(0).getId());
}

@Test
void isMcpAuthorizationHeaderDetectsBearerPrefix() {
assertTrue(McpTokenService.isMcpAuthorizationHeader("Bearer dsql_mcp_abc.secret"));
assertFalse(McpTokenService.isMcpAuthorizationHeader("Bearer eyJhbGciOi"));
assertFalse(McpTokenService.isMcpAuthorizationHeader("dsql_mcp_abc.secret"));
assertFalse(McpTokenService.isMcpAuthorizationHeader(null));
assertFalse(McpTokenService.isMcpAuthorizationHeader(""));
}
}
Loading
Loading