From eff6fcfa3e4cd3567c148082d1653b6ea501a221 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 23 Apr 2026 10:39:45 +0700 Subject: [PATCH 1/9] feat: MCP three-tier query safety with server-side confirmation - Safe (SELECT/SHOW/EXPLAIN): executes immediately - Write (UPDATE/DELETE): forces native macOS confirmation dialog - Destructive (DROP/TRUNCATE): requires confirm_destructive_operation tool + native dialog - INSERT exempt from confirmation (additive, non-destructive) - CTE-prefixed writes (WITH...DELETE) properly detected - RETURNING clause routes to fetchRows for result rows - Multi-statement queries blocked via SQLStatementScanner - Server-side trust model: confirmation cannot be bypassed by MCP client --- CHANGELOG.md | 4 + TablePro/Core/MCP/MCPConnectionBridge.swift | 14 +- TablePro/Core/MCP/MCPRouter.swift | 30 +++- TablePro/Core/MCP/MCPToolHandler.swift | 158 ++++++++++++++---- .../Core/Utilities/SQL/QueryClassifier.swift | 72 +++++++- 5 files changed, 242 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f5c8cdf4..a5f86b2d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- MCP query safety: SELECT runs immediately, UPDATE/DELETE require server-side confirmation via native macOS dialog, DROP/TRUNCATE require explicit confirmation tool with native dialog + ## [0.34.0] - 2026-04-22 ### Added diff --git a/TablePro/Core/MCP/MCPConnectionBridge.swift b/TablePro/Core/MCP/MCPConnectionBridge.swift index a5903d5b9..9f2e32362 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge.swift @@ -169,19 +169,25 @@ actor MCPConnectionBridge { maxRows: Int, timeoutSeconds: Int ) async throws -> JSONValue { - let (driver, _) = try await resolveDriver(connectionId) + let (driver, databaseType) = try await resolveDriver(connectionId) + let isWrite = QueryClassifier.isWriteQuery(query, databaseType: databaseType) + let uppercased = query.uppercased() + let hasReturning = uppercased.range(of: #"\bRETURNING\b"#, options: .regularExpression) != nil + let shouldUseFetchRows = !isWrite || hasReturning let effectiveLimit = maxRows + 1 let startTime = CFAbsoluteTimeGetCurrent() - // trackOperation is @MainActor; Swift hops automatically. - // The driver.fetchRows call inside runs on the cooperative pool. let result: QueryResult = try await DatabaseManager.shared.trackOperation( sessionId: connectionId ) { try await withThrowingTaskGroup(of: QueryResult.self) { group in group.addTask { - try await driver.fetchRows(query: query, offset: 0, limit: effectiveLimit) + if shouldUseFetchRows { + try await driver.fetchRows(query: query, offset: 0, limit: effectiveLimit) + } else { + try await driver.execute(query: query) + } } group.addTask { try await Task.sleep(for: .seconds(timeoutSeconds)) diff --git a/TablePro/Core/MCP/MCPRouter.swift b/TablePro/Core/MCP/MCPRouter.swift index 5cdbd3978..c30b51af2 100644 --- a/TablePro/Core/MCP/MCPRouter.swift +++ b/TablePro/Core/MCP/MCPRouter.swift @@ -646,7 +646,9 @@ extension MCPRouter { [ MCPToolDefinition( name: "execute_query", - description: "Execute a SQL or NoSQL query on a connected database", + description: "Execute a SQL query. All queries are subject to the connection's safe mode policy. " + + "UPDATE/DELETE queries always require user confirmation via a native dialog. " + + "DROP/TRUNCATE/ALTER...DROP must use the confirm_destructive_operation tool.", inputSchema: .object([ "type": "object", "properties": .object([ @@ -713,6 +715,32 @@ extension MCPRouter { ]), "required": .array([.string("connection_id"), .string("format")]) ]) + ), + MCPToolDefinition( + name: "confirm_destructive_operation", + description: "Execute a destructive DDL query (DROP, TRUNCATE, ALTER...DROP) after explicit confirmation.", + inputSchema: .object([ + "type": "object", + "properties": .object([ + "connection_id": .object([ + "type": "string", + "description": "UUID of the active connection" + ]), + "query": .object([ + "type": "string", + "description": "The destructive query to execute" + ]), + "confirmation_phrase": .object([ + "type": "string", + "description": "Must be exactly: I understand this is irreversible" + ]) + ]), + "required": .array([ + .string("connection_id"), + .string("query"), + .string("confirmation_phrase") + ]) + ]) ) ] } diff --git a/TablePro/Core/MCP/MCPToolHandler.swift b/TablePro/Core/MCP/MCPToolHandler.swift index 9f2972eb1..1fcea3975 100644 --- a/TablePro/Core/MCP/MCPToolHandler.swift +++ b/TablePro/Core/MCP/MCPToolHandler.swift @@ -43,6 +43,8 @@ final class MCPToolHandler: Sendable { return try await handleGetTableDDL(arguments, sessionId: sessionId) case "export_data": return try await handleExportData(arguments, sessionId: sessionId) + case "confirm_destructive_operation": + return try await handleConfirmDestructiveOperation(arguments, sessionId: sessionId) case "switch_database": return try await handleSwitchDatabase(arguments, sessionId: sessionId) case "switch_schema": @@ -94,6 +96,10 @@ final class MCPToolHandler: Sendable { throw MCPError.invalidParams("Query exceeds 100KB limit") } + guard !QueryClassifier.isMultiStatement(query) else { + throw MCPError.invalidParams("Multi-statement queries are not supported. Send one statement at a time.") + } + try await authGuard.checkConnectionAccess(connectionId: connectionId, sessionId: sessionId) let (databaseType, safeModeLevel, databaseName) = try await resolveConnectionMeta(connectionId) @@ -105,46 +111,95 @@ final class MCPToolHandler: Sendable { _ = try await bridge.switchSchema(connectionId: connectionId, schema: schema) } - try await authGuard.checkQueryPermission( - sql: query, - connectionId: connectionId, - databaseType: databaseType, - safeModeLevel: safeModeLevel - ) + let tier = QueryClassifier.classifyTier(query, databaseType: databaseType) - let startTime = Date() - let result: JSONValue - do { - result = try await bridge.executeQuery( - connectionId: connectionId, - query: query, - maxRows: maxRows, - timeoutSeconds: timeoutSeconds + switch tier { + case .destructive: + throw MCPError.forbidden( + "Destructive queries (DROP, TRUNCATE, ALTER...DROP) cannot be executed via execute_query. " + + "Use the confirm_destructive_operation tool instead." ) - let elapsed = Date().timeIntervalSince(startTime) - await authGuard.logQuery( + + case .write where !QueryClassifier.isInsertQuery(query): + try await authGuard.checkQueryPermission( sql: query, connectionId: connectionId, - databaseName: databaseName, - executionTime: elapsed, - rowCount: result["row_count"]?.intValue ?? 0, - wasSuccessful: true, - errorMessage: nil + databaseType: databaseType, + safeModeLevel: .alert ) - } catch { - let elapsed = Date().timeIntervalSince(startTime) - await authGuard.logQuery( + + case .safe, .write: + try await authGuard.checkQueryPermission( sql: query, connectionId: connectionId, - databaseName: databaseName, - executionTime: elapsed, - rowCount: 0, - wasSuccessful: false, - errorMessage: error.localizedDescription + databaseType: databaseType, + safeModeLevel: safeModeLevel ) - throw error } + let result = try await executeAndLog( + query: query, + connectionId: connectionId, + databaseName: databaseName, + maxRows: maxRows, + timeoutSeconds: timeoutSeconds + ) + + return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) + } + + // MARK: - Destructive Confirmation + + private func handleConfirmDestructiveOperation( + _ args: JSONValue?, + sessionId: String + ) async throws -> MCPToolResult { + let connectionId = try requireUUID(args, key: "connection_id") + let query = try requireString(args, key: "query") + let confirmationPhrase = try requireString(args, key: "confirmation_phrase") + + guard confirmationPhrase == "I understand this is irreversible" else { + throw MCPError.invalidParams( + "confirmation_phrase must be exactly: I understand this is irreversible" + ) + } + + guard !QueryClassifier.isMultiStatement(query) else { + throw MCPError.invalidParams( + "Multi-statement queries are not supported. Send one statement at a time." + ) + } + + try await authGuard.checkConnectionAccess(connectionId: connectionId, sessionId: sessionId) + + let (databaseType, _, databaseName) = try await resolveConnectionMeta(connectionId) + + let tier = QueryClassifier.classifyTier(query, databaseType: databaseType) + guard tier == .destructive else { + throw MCPError.invalidParams( + "This tool only accepts destructive queries (DROP, TRUNCATE, ALTER...DROP). " + + "Use execute_query for other queries." + ) + } + + try await authGuard.checkQueryPermission( + sql: query, + connectionId: connectionId, + databaseType: databaseType, + safeModeLevel: .alert + ) + + let mcpSettings = await MainActor.run { AppSettingsManager.shared.mcp } + let timeoutSeconds = mcpSettings.queryTimeoutSeconds + + let result = try await executeAndLog( + query: query, + connectionId: connectionId, + databaseName: databaseName, + maxRows: 0, + timeoutSeconds: timeoutSeconds + ) + return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) } @@ -344,6 +399,49 @@ final class MCPToolHandler: Sendable { return MCPToolResult(content: [.text(encodeJSON(result))], isError: nil) } + // MARK: - Execute and Log + + private func executeAndLog( + query: String, + connectionId: UUID, + databaseName: String, + maxRows: Int, + timeoutSeconds: Int + ) async throws -> JSONValue { + let startTime = Date() + do { + let result = try await bridge.executeQuery( + connectionId: connectionId, + query: query, + maxRows: maxRows, + timeoutSeconds: timeoutSeconds + ) + let elapsed = Date().timeIntervalSince(startTime) + await authGuard.logQuery( + sql: query, + connectionId: connectionId, + databaseName: databaseName, + executionTime: elapsed, + rowCount: result["row_count"]?.intValue ?? 0, + wasSuccessful: true, + errorMessage: nil + ) + return result + } catch { + let elapsed = Date().timeIntervalSince(startTime) + await authGuard.logQuery( + sql: query, + connectionId: connectionId, + databaseName: databaseName, + executionTime: elapsed, + rowCount: 0, + wasSuccessful: false, + errorMessage: error.localizedDescription + ) + throw error + } + } + // MARK: - Parameter Helpers private func requireUUID(_ args: JSONValue?, key: String) throws -> UUID { diff --git a/TablePro/Core/Utilities/SQL/QueryClassifier.swift b/TablePro/Core/Utilities/SQL/QueryClassifier.swift index e93428cea..bebcd8bbe 100644 --- a/TablePro/Core/Utilities/SQL/QueryClassifier.swift +++ b/TablePro/Core/Utilities/SQL/QueryClassifier.swift @@ -5,6 +5,12 @@ import Foundation +enum QueryTier { + case safe + case write + case destructive +} + enum QueryClassifier { private static let writeQueryPrefixes: [String] = [ "INSERT ", "UPDATE ", "DELETE ", "REPLACE ", @@ -40,7 +46,18 @@ enum QueryClassifier { } let uppercased = trimmed.uppercased() - return writeQueryPrefixes.contains { uppercased.hasPrefix($0) } + if writeQueryPrefixes.contains(where: { uppercased.hasPrefix($0) }) { + return true + } + + if uppercased.hasPrefix("WITH ") { + let dmlKeywords = ["INSERT ", "UPDATE ", "DELETE ", "MERGE "] + for keyword in dmlKeywords where uppercased.contains(keyword) { + return true + } + } + + return false } static func isDangerousQuery(_ sql: String, databaseType: DatabaseType) -> Bool { @@ -73,4 +90,57 @@ enum QueryClassifier { return false } + + static func classifyTier(_ sql: String, databaseType: DatabaseType) -> QueryTier { + let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) + let uppercased = trimmed.uppercased() + + if databaseType == .redis { + let firstToken = trimmed.prefix(while: { !$0.isWhitespace }).uppercased() + if firstToken == "FLUSHDB" || firstToken == "FLUSHALL" { + return .destructive + } + } else { + if uppercased.hasPrefix("DROP ") || uppercased.hasPrefix("TRUNCATE ") { + return .destructive + } + if uppercased.hasPrefix("ALTER ") && uppercased.range(of: " DROP ", options: .literal) != nil { + return .destructive + } + + if uppercased.hasPrefix("WITH ") { + let destructiveKeywords = ["DROP ", "TRUNCATE "] + for keyword in destructiveKeywords where uppercased.contains(keyword) { + return .destructive + } + let writeKeywords = ["INSERT ", "UPDATE ", "DELETE ", "MERGE "] + for keyword in writeKeywords where uppercased.contains(keyword) { + return .write + } + } + } + + if isWriteQuery(sql, databaseType: databaseType) { + return .write + } + + return .safe + } + + static func isInsertQuery(_ sql: String) -> Bool { + let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.uppercased().hasPrefix("INSERT ") + } + + static func isMultiStatement(_ sql: String) -> Bool { + SQLStatementScanner.allStatements(in: sql).count > 1 + } + + private static let whereKeywordRegex = try? NSRegularExpression(pattern: "\\bWHERE\\b", options: .caseInsensitive) + + static func hasWhereClause(_ sql: String) -> Bool { + let uppercased = sql.uppercased() + let range = NSRange(uppercased.startIndex..., in: uppercased) + return whereKeywordRegex?.firstMatch(in: uppercased, options: [], range: range) != nil + } } From a61983536caac9cf5307feeb8cccf2725ea6bdfc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 23 Apr 2026 10:44:09 +0700 Subject: [PATCH 2/9] fix: MCP confirmation dialog appears on key window instead of bouncing dock icon --- TablePro/Core/MCP/MCPAuthGuard.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/TablePro/Core/MCP/MCPAuthGuard.swift b/TablePro/Core/MCP/MCPAuthGuard.swift index dd1cea7f4..b7c1fba40 100644 --- a/TablePro/Core/MCP/MCPAuthGuard.swift +++ b/TablePro/Core/MCP/MCPAuthGuard.swift @@ -67,12 +67,16 @@ actor MCPAuthGuard { let isWrite = QueryClassifier.isWriteQuery(sql, databaseType: databaseType) // SafeModeGuard.checkPermission is @MainActor async; Swift hops automatically + let window = await MainActor.run { + NSApp.activate(ignoringOtherApps: true) + return NSApp.keyWindow ?? NSApp.mainWindow + } let permission = await SafeModeGuard.checkPermission( level: safeModeLevel, isWriteOperation: isWrite, sql: sql, operationDescription: String(localized: "MCP query execution"), - window: nil, + window: window, databaseType: databaseType ) From 8e4375bdfcab428f1a642a3f57d349d112b12d0a Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 23 Apr 2026 10:53:16 +0700 Subject: [PATCH 3/9] fix: respect connection safe mode level for MCP writes instead of forced alert --- TablePro/Core/MCP/MCPToolHandler.swift | 14 +++----------- 1 file changed, 3 insertions(+), 11 deletions(-) diff --git a/TablePro/Core/MCP/MCPToolHandler.swift b/TablePro/Core/MCP/MCPToolHandler.swift index 1fcea3975..1c2972222 100644 --- a/TablePro/Core/MCP/MCPToolHandler.swift +++ b/TablePro/Core/MCP/MCPToolHandler.swift @@ -120,15 +120,7 @@ final class MCPToolHandler: Sendable { + "Use the confirm_destructive_operation tool instead." ) - case .write where !QueryClassifier.isInsertQuery(query): - try await authGuard.checkQueryPermission( - sql: query, - connectionId: connectionId, - databaseType: databaseType, - safeModeLevel: .alert - ) - - case .safe, .write: + case .write, .safe: try await authGuard.checkQueryPermission( sql: query, connectionId: connectionId, @@ -172,7 +164,7 @@ final class MCPToolHandler: Sendable { try await authGuard.checkConnectionAccess(connectionId: connectionId, sessionId: sessionId) - let (databaseType, _, databaseName) = try await resolveConnectionMeta(connectionId) + let (databaseType, safeModeLevel, databaseName) = try await resolveConnectionMeta(connectionId) let tier = QueryClassifier.classifyTier(query, databaseType: databaseType) guard tier == .destructive else { @@ -186,7 +178,7 @@ final class MCPToolHandler: Sendable { sql: query, connectionId: connectionId, databaseType: databaseType, - safeModeLevel: .alert + safeModeLevel: safeModeLevel ) let mcpSettings = await MainActor.run { AppSettingsManager.shared.mcp } From f475412bb3f2701ae07a992bde0aad61a3b82916 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 23 Apr 2026 10:58:21 +0700 Subject: [PATCH 4/9] fix: accurate tool description, only activate app when dialog will show --- TablePro/Core/MCP/MCPAuthGuard.swift | 12 ++++++++---- TablePro/Core/MCP/MCPRouter.swift | 1 - 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/TablePro/Core/MCP/MCPAuthGuard.swift b/TablePro/Core/MCP/MCPAuthGuard.swift index b7c1fba40..049c9d2b1 100644 --- a/TablePro/Core/MCP/MCPAuthGuard.swift +++ b/TablePro/Core/MCP/MCPAuthGuard.swift @@ -65,12 +65,16 @@ actor MCPAuthGuard { safeModeLevel: SafeModeLevel ) async throws { let isWrite = QueryClassifier.isWriteQuery(sql, databaseType: databaseType) + let needsDialog = safeModeLevel != .silent && (isWrite || safeModeLevel == .alertFull || safeModeLevel == .safeModeFull) - // SafeModeGuard.checkPermission is @MainActor async; Swift hops automatically - let window = await MainActor.run { - NSApp.activate(ignoringOtherApps: true) - return NSApp.keyWindow ?? NSApp.mainWindow + var window: NSWindow? + if needsDialog { + window = await MainActor.run { + NSApp.activate(ignoringOtherApps: true) + return NSApp.keyWindow ?? NSApp.mainWindow + } } + let permission = await SafeModeGuard.checkPermission( level: safeModeLevel, isWriteOperation: isWrite, diff --git a/TablePro/Core/MCP/MCPRouter.swift b/TablePro/Core/MCP/MCPRouter.swift index c30b51af2..e1c594ece 100644 --- a/TablePro/Core/MCP/MCPRouter.swift +++ b/TablePro/Core/MCP/MCPRouter.swift @@ -647,7 +647,6 @@ extension MCPRouter { MCPToolDefinition( name: "execute_query", description: "Execute a SQL query. All queries are subject to the connection's safe mode policy. " - + "UPDATE/DELETE queries always require user confirmation via a native dialog. " + "DROP/TRUNCATE/ALTER...DROP must use the confirm_destructive_operation tool.", inputSchema: .object([ "type": "object", From c52d885522b1d446636e59e76d151940642715dc Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 23 Apr 2026 10:59:51 +0700 Subject: [PATCH 5/9] docs: simplify MCP safety CHANGELOG entry --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5f86b2d5..4d8ece3b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- MCP query safety: SELECT runs immediately, UPDATE/DELETE require server-side confirmation via native macOS dialog, DROP/TRUNCATE require explicit confirmation tool with native dialog +- MCP query safety: three-tier classification with server-side confirmation for write and destructive queries ## [0.34.0] - 2026-04-22 From 9bb419928da379fbbe424fca20a913471a693fe7 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 23 Apr 2026 11:01:56 +0700 Subject: [PATCH 6/9] docs: add security, privacy and data safety section to MCP docs --- docs/features/mcp.mdx | 59 +++++++++++++++++++++++++++++++++++-------- 1 file changed, 49 insertions(+), 10 deletions(-) diff --git a/docs/features/mcp.mdx b/docs/features/mcp.mdx index 9ab5b4507..c1127e2d1 100644 --- a/docs/features/mcp.mdx +++ b/docs/features/mcp.mdx @@ -75,7 +75,8 @@ Add the snippet to your `.cursor/mcp.json` file in the project root, or to the g | `list_tables` | List tables and views | | `describe_table` | Get columns, indexes, foreign keys, and DDL | | `get_table_ddl` | Get the CREATE TABLE statement | -| `execute_query` | Run a SQL or NoSQL query | +| `execute_query` | Run a SQL or NoSQL query (safe mode applies) | +| `confirm_destructive_operation` | Execute DROP/TRUNCATE after explicit confirmation | | `export_data` | Export results as CSV, JSON, or SQL | | `switch_database` | Switch the active database | | `switch_schema` | Switch the active schema | @@ -86,23 +87,61 @@ The server also exposes three resources that AI tools can read directly: - `tablepro://connections/{id}/schema` - full schema for a connected database - `tablepro://connections/{id}/history` - recent query history (supports `?limit=`, `?search=`, `?date_filter=`) -## Security +## Security & Privacy -### AI Connection Policy + +All safety controls run server-side in TablePro. AI clients cannot bypass them. + -Each connection has an **AI Policy** that controls MCP access: +### Localhost only -- **Always Allow** - MCP tools can use this connection without prompts -- **Ask Each Time** - TablePro shows an approval dialog when an MCP client first accesses the connection in a session -- **Never** - MCP access is blocked entirely +The server binds to `127.0.0.1`. It is not exposed to the network. -Set the policy in the connection's settings. The default policy for new connections is configurable in **Settings > AI**. +### Connection access + +Each connection has an AI Policy: + +| Policy | Behavior | +|--------|----------| +| Always Allow | No prompt | +| Ask Each Time (default) | Approval dialog on first access per session | +| Never | Blocked | + +Set per connection or change the default in **Settings > AI**. + +### Query tiers + +| Tier | Examples | What happens | +|------|----------|-------------| +| Safe | SELECT, SHOW, EXPLAIN | Runs immediately | +| Write | UPDATE, DELETE, INSERT | Uses the connection's [Safe Mode](/features/safe-mode) | +| Destructive | DROP, TRUNCATE | Blocked. Use `confirm_destructive_operation` tool | ### Safe Mode -[Safe Mode](/features/safe-mode) applies to MCP queries the same way it applies to manual queries. If a connection is set to Alert or higher, write queries from MCP clients require the same confirmation or Touch ID authentication. +MCP queries follow the same Safe Mode as manual queries: + +- **Silent** - no confirmation +- **Read-Only** - writes blocked +- **Alert** - confirmation dialog for writes +- **Safe Mode** - confirmation + Touch ID + +### Limits + +- Row limit: 500 default, 10,000 max +- Query timeout: 30s default, 300s max +- Single statements only (multi-statement blocked) +- Query size: 100 KB max + +### Logging + +MCP queries are logged to [Query History](/features/query-history) (can be disabled in Settings). + +### Data access + +MCP clients **can** access: connection metadata, schemas, query results, query history. -Read-Only connections block all write queries from MCP clients. +MCP clients **cannot** access: passwords, SSH keys, license data, app settings, files on your Mac. ## Troubleshooting From f72f539a048a0ae96d3a6d6005fe1b362d3a872d Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 23 Apr 2026 11:05:01 +0700 Subject: [PATCH 7/9] docs: expand MCP security with destructive query usage, full safe mode levels --- docs/features/mcp.mdx | 30 +++++++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/docs/features/mcp.mdx b/docs/features/mcp.mdx index c1127e2d1..34eeda8db 100644 --- a/docs/features/mcp.mdx +++ b/docs/features/mcp.mdx @@ -104,7 +104,7 @@ Each connection has an AI Policy: | Policy | Behavior | |--------|----------| | Always Allow | No prompt | -| Ask Each Time (default) | Approval dialog on first access per session | +| Ask Each Time (default) | Approval dialog on first access per session (30s timeout) | | Never | Blocked | Set per connection or change the default in **Settings > AI**. @@ -115,16 +115,36 @@ Set per connection or change the default in **Settings > AI**. |------|----------|-------------| | Safe | SELECT, SHOW, EXPLAIN | Runs immediately | | Write | UPDATE, DELETE, INSERT | Uses the connection's [Safe Mode](/features/safe-mode) | -| Destructive | DROP, TRUNCATE | Blocked. Use `confirm_destructive_operation` tool | +| Destructive | DROP, TRUNCATE, ALTER...DROP | Blocked in `execute_query`. Must use `confirm_destructive_operation` | + +CTE-prefixed writes like `WITH ... DELETE FROM` are detected and classified correctly. + +#### Destructive queries + +`execute_query` rejects destructive queries with an error. Use `confirm_destructive_operation` instead: + +```json +{ + "connection_id": "uuid", + "query": "DROP TABLE temp_data", + "confirmation_phrase": "I understand this is irreversible" +} +``` + +The confirmation phrase must match exactly. The connection's Safe Mode still applies on top of this. ### Safe Mode -MCP queries follow the same Safe Mode as manual queries: +MCP queries follow the same [Safe Mode](/features/safe-mode) as manual queries: - **Silent** - no confirmation -- **Read-Only** - writes blocked +- **Read-Only** - all writes blocked - **Alert** - confirmation dialog for writes -- **Safe Mode** - confirmation + Touch ID +- **Alert (Full)** - confirmation dialog for all queries including reads +- **Safe Mode** - confirmation + Touch ID for writes +- **Safe Mode (Full)** - confirmation + Touch ID for all queries + +If denied, the query returns an error to the MCP client. Nothing is executed. ### Limits From 2206c082bb562be7c8f2cf9b193d92339cf3eb98 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 23 Apr 2026 11:09:28 +0700 Subject: [PATCH 8/9] docs: complete MCP docs with all parameters, resources, limits, and security details Also fix export_data tool description claiming XLSX support (not implemented). --- TablePro/Core/MCP/MCPRouter.swift | 2 +- docs/features/mcp.mdx | 78 +++++++++++++++++++++---------- 2 files changed, 54 insertions(+), 26 deletions(-) diff --git a/TablePro/Core/MCP/MCPRouter.swift b/TablePro/Core/MCP/MCPRouter.swift index e1c594ece..2b692031d 100644 --- a/TablePro/Core/MCP/MCPRouter.swift +++ b/TablePro/Core/MCP/MCPRouter.swift @@ -681,7 +681,7 @@ extension MCPRouter { ), MCPToolDefinition( name: "export_data", - description: "Export query results or table data to CSV, JSON, SQL, or XLSX", + description: "Export query results or table data to CSV, JSON, or SQL", inputSchema: .object([ "type": "object", "properties": .object([ diff --git a/docs/features/mcp.mdx b/docs/features/mcp.mdx index 34eeda8db..c9842da81 100644 --- a/docs/features/mcp.mdx +++ b/docs/features/mcp.mdx @@ -9,7 +9,7 @@ TablePro includes a built-in [Model Context Protocol](https://modelcontextprotoc ## Enabling the Server -Open **Settings > Terminal** and toggle **Enable MCP Server**. The server starts on port `23508` by default. A green status indicator confirms it is running. +Open **Settings > MCP** and toggle **Enable MCP Server**. The server starts on port `23508` by default. A green status indicator confirms it is running. -### Localhost only +### Network -The server binds to `127.0.0.1`. It is not exposed to the network. +- Binds to `127.0.0.1` only. Never exposed to the network. +- Origin header validated to prevent DNS rebinding attacks. Only `localhost`, `127.0.0.1`, `[::1]` allowed. ### Connection access @@ -107,7 +131,7 @@ Each connection has an AI Policy: | Ask Each Time (default) | Approval dialog on first access per session (30s timeout) | | Never | Blocked | -Set per connection or change the default in **Settings > AI**. +Set per connection or change the default in **Settings > AI**. Approvals are cleared when the session ends, times out, or the app restarts. ### Query tiers @@ -148,10 +172,14 @@ If denied, the query returns an error to the MCP client. Nothing is executed. ### Limits -- Row limit: 500 default, 10,000 max -- Query timeout: 30s default, 300s max +- Row limit: 500 default, 10,000 max (per query via `max_rows`) +- Export row limit: 50,000 default, 100,000 max +- Query timeout: 30s default, 300s max (per query via `timeout_seconds`) - Single statements only (multi-statement blocked) - Query size: 100 KB max +- HTTP body: 10 MB max +- Max concurrent sessions: 10 +- Session idle timeout: 5 minutes ### Logging @@ -165,12 +193,12 @@ MCP clients **cannot** access: passwords, SSH keys, license data, app settings, ## Troubleshooting -**Port conflict**: If the server fails to start, another process may be using port 23508. Change the port in **Settings > Terminal**. +**Port conflict**: If the server fails to start, another process may be using port 23508. Change the port in **Settings > MCP**. -**"Server not fully initialized"**: Restart the MCP server from **Settings > Terminal**. If the issue persists, quit and relaunch TablePro. +**"Server not fully initialized"**: Restart the MCP server from **Settings > MCP**. If the issue persists, quit and relaunch TablePro. **App must be running**: The MCP server only runs while TablePro is open. AI tools cannot connect if the app is quit or the server is disabled. **Connection refused**: Verify the server is running (green indicator in Settings). Check that your AI tool's MCP config URL matches the port shown in Settings. -**Query blocked by Safe Mode**: The MCP server respects the same Safe Mode levels as manual queries. Check the connection's safe mode setting if queries are being rejected. +**Query blocked by Safe Mode**: Check the connection's safe mode setting if queries are being rejected. From 1246f25596ff41f05b7522d3a9212458d8a51809 Mon Sep 17 00:00:00 2001 From: Ngo Quoc Dat Date: Thu, 23 Apr 2026 11:18:00 +0700 Subject: [PATCH 9/9] fix: remove dead code (hasWhereClause, isInsertQuery), avoid uppercased() copy in bridge --- TablePro/Core/MCP/MCPConnectionBridge.swift | 3 +-- TablePro/Core/Utilities/SQL/QueryClassifier.swift | 13 ------------- 2 files changed, 1 insertion(+), 15 deletions(-) diff --git a/TablePro/Core/MCP/MCPConnectionBridge.swift b/TablePro/Core/MCP/MCPConnectionBridge.swift index 9f2e32362..d0dfbfb69 100644 --- a/TablePro/Core/MCP/MCPConnectionBridge.swift +++ b/TablePro/Core/MCP/MCPConnectionBridge.swift @@ -171,8 +171,7 @@ actor MCPConnectionBridge { ) async throws -> JSONValue { let (driver, databaseType) = try await resolveDriver(connectionId) let isWrite = QueryClassifier.isWriteQuery(query, databaseType: databaseType) - let uppercased = query.uppercased() - let hasReturning = uppercased.range(of: #"\bRETURNING\b"#, options: .regularExpression) != nil + let hasReturning = query.range(of: #"\bRETURNING\b"#, options: [.regularExpression, .caseInsensitive]) != nil let shouldUseFetchRows = !isWrite || hasReturning let effectiveLimit = maxRows + 1 diff --git a/TablePro/Core/Utilities/SQL/QueryClassifier.swift b/TablePro/Core/Utilities/SQL/QueryClassifier.swift index bebcd8bbe..78a4f1425 100644 --- a/TablePro/Core/Utilities/SQL/QueryClassifier.swift +++ b/TablePro/Core/Utilities/SQL/QueryClassifier.swift @@ -127,20 +127,7 @@ enum QueryClassifier { return .safe } - static func isInsertQuery(_ sql: String) -> Bool { - let trimmed = sql.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.uppercased().hasPrefix("INSERT ") - } - static func isMultiStatement(_ sql: String) -> Bool { SQLStatementScanner.allStatements(in: sql).count > 1 } - - private static let whereKeywordRegex = try? NSRegularExpression(pattern: "\\bWHERE\\b", options: .caseInsensitive) - - static func hasWhereClause(_ sql: String) -> Bool { - let uppercased = sql.uppercased() - let range = NSRange(uppercased.startIndex..., in: uppercased) - return whereKeywordRegex?.firstMatch(in: uppercased, options: [], range: range) != nil - } }