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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Saving on a MySQL or MariaDB server that starts sessions read-only no longer fails with "Cannot execute statement in a READ ONLY transaction". TablePro marks a transaction read-write before it writes instead of inheriting the server default. Same for PostgreSQL, CockroachDB, and Redshift. (#2009)
- Changing Safe Mode in the connection form now applies to an open connection instead of waiting for a reconnect. (#2009)
- A read-only error now says whether the database server or Safe Mode refused the write. (#2009)
- The MCP server's **Default row limit** and **Maximum row limit** now apply to the `export_data` tool, so raising the maximum really does export more rows. Export previously ignored both and used a fixed 50,000, so an export with no `max_rows` now returns the default row limit (500) until you raise it. Export also honours the configured query timeout, reports whether the limit clipped the result, and appears in query history and the activity log like other MCP queries. (#2012)
- Exporting a table over MCP no longer fails on SQL Server, Oracle, and Teradata. The row limit is now written in each database's own syntax instead of always using `LIMIT`. (#2012)
- Setting the MCP server's row limits or query timeout to zero or a negative number no longer crashes the app on the next tool call. Values outside the supported range are corrected as you type. (#2012)
- AI chat no longer crashes when the model asks a tool for a row limit or timeout larger than TablePro can count to. The tool now falls back to the configured limit. (#2012)

## [0.62.0] - 2026-08-02

Expand Down
12 changes: 6 additions & 6 deletions TablePro/Core/AI/Chat/ChatToolArgumentDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,17 +41,17 @@ enum ChatToolArgumentDecoder {
static func optionalInt(
_ args: JsonValue,
key: String,
default fallback: Int,
default fallback: Int? = nil,
clamp: ClosedRange<Int>? = nil
) -> Int? {
guard case .object(let dict) = args, let value = dict[key] else { return fallback }
let raw: Int?
let decoded: Int?
switch value {
case .int(let int): raw = int
case .double(let double): raw = Int(double)
default: raw = nil
case .int(let int): decoded = int
case .double(let double): decoded = Int(exactly: double.rounded(.towardZero))
default: decoded = nil
}
guard let raw else { return fallback }
guard let raw = decoded ?? fallback else { return nil }
if let clamp { return max(clamp.lowerBound, min(raw, clamp.upperBound)) }
return raw
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ struct ConfirmDestructiveOperationChatTool: ChatTool {
connectionId: connectionId,
databaseName: meta.databaseName,
maxRows: 0,
timeoutSeconds: mcpSettings.queryTimeoutSeconds,
timeoutSeconds: MCPLimitResolver.resolveTimeoutSeconds(requested: nil, settings: mcpSettings),
principalLabel: String(localized: "AI Chat")
)
return ChatToolResult(content: payload.jsonString(prettyPrinted: true))
Expand Down
24 changes: 10 additions & 14 deletions TablePro/Core/AI/Chat/Tools/ExecuteQueryChatTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ struct ExecuteQueryChatTool: ChatTool {
"connection_id": ChatToolSchemaBuilder.connectionId,
"query": ChatToolSchemaBuilder.string(description: "SQL or NoSQL query text"),
"max_rows": ChatToolSchemaBuilder.integer(
description: "Maximum rows to return (default 500, max 10000). Pass null to use default.",
description: "Maximum rows to return, capped at the server's configured maximum row limit. Pass null to use the configured default row limit.",
optional: true
),
"timeout_seconds": ChatToolSchemaBuilder.integer(
description: "Query timeout in seconds (default 30, max 300). Pass null to use default.",
description: "Query timeout in seconds (max 300). Pass null to use the server's configured query timeout.",
optional: true
),
"database": ChatToolSchemaBuilder.string(
Expand Down Expand Up @@ -56,18 +56,14 @@ struct ExecuteQueryChatTool: ChatTool {
}

let mcpSettings = await MainActor.run { AppSettingsManager.shared.mcp }
let maxRows = ChatToolArgumentDecoder.optionalInt(
input,
key: "max_rows",
default: mcpSettings.defaultRowLimit,
clamp: 1...mcpSettings.maxRowLimit
) ?? mcpSettings.defaultRowLimit
let timeoutSeconds = ChatToolArgumentDecoder.optionalInt(
input,
key: "timeout_seconds",
default: mcpSettings.queryTimeoutSeconds,
clamp: 1...300
) ?? mcpSettings.queryTimeoutSeconds
let maxRows = MCPLimitResolver.resolveMaxRows(
requested: ChatToolArgumentDecoder.optionalInt(input, key: "max_rows"),
settings: mcpSettings
)
let timeoutSeconds = MCPLimitResolver.resolveTimeoutSeconds(
requested: ChatToolArgumentDecoder.optionalInt(input, key: "timeout_seconds"),
settings: mcpSettings
)

let tier = QueryClassifier.classifyTier(query, databaseType: meta.databaseType)
if tier == .destructive {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ public struct ConfirmDestructiveOperationTool: MCPToolImplementation {
capabilities: [.mayWrite, .mayRunDestructive, .confirmationPreCleared]
)

let mcpSettings = await MainActor.run { AppSettingsManager.shared.mcp }
let timeoutSeconds = mcpSettings.queryTimeoutSeconds
let mcpSettings = await services.settingsProvider()
let timeoutSeconds = MCPLimitResolver.resolveTimeoutSeconds(requested: nil, settings: mcpSettings)

Self.logger.debug("confirm_destructive_operation invoked for connection \(connectionId.uuidString, privacy: .public)")

Expand Down
26 changes: 11 additions & 15 deletions TablePro/Core/MCP/Protocol/Tools/ExecuteQueryTool.swift
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,11 @@ public struct ExecuteQueryTool: MCPToolImplementation {
]),
"max_rows": .object([
"type": .string("integer"),
"description": .string(String(localized: "Maximum rows to return (default 500, max 10000)"))
"description": .string(String(localized: "Maximum rows to return. Defaults to the server's configured default row limit and is capped at its maximum row limit."))
]),
"timeout_seconds": .object([
"type": .string("integer"),
"description": .string(String(localized: "Query timeout in seconds (default 30, max 300)"))
"description": .string(String(localized: "Query timeout in seconds (max 300). Defaults to the server's configured query timeout."))
]),
"database": .object([
"type": .string("string"),
Expand Down Expand Up @@ -57,19 +57,15 @@ public struct ExecuteQueryTool: MCPToolImplementation {
let connectionId = try MCPArgumentDecoder.requireUuid(arguments, key: "connection_id")
let query = try MCPArgumentDecoder.requireString(arguments, key: "query")

let mcpSettings = await MainActor.run { AppSettingsManager.shared.mcp }
let maxRows = MCPArgumentDecoder.optionalInt(
arguments,
key: "max_rows",
default: mcpSettings.defaultRowLimit,
clamp: 1...mcpSettings.maxRowLimit
) ?? mcpSettings.defaultRowLimit
let timeoutSeconds = MCPArgumentDecoder.optionalInt(
arguments,
key: "timeout_seconds",
default: mcpSettings.queryTimeoutSeconds,
clamp: 1...300
) ?? mcpSettings.queryTimeoutSeconds
let mcpSettings = await services.settingsProvider()
let maxRows = MCPLimitResolver.resolveMaxRows(
requested: MCPArgumentDecoder.optionalInt(arguments, key: "max_rows"),
settings: mcpSettings
)
let timeoutSeconds = MCPLimitResolver.resolveTimeoutSeconds(
requested: MCPArgumentDecoder.optionalInt(arguments, key: "timeout_seconds"),
settings: mcpSettings
)
let database = MCPArgumentDecoder.optionalString(arguments, key: "database")
let schema = MCPArgumentDecoder.optionalString(arguments, key: "schema")

Expand Down
72 changes: 57 additions & 15 deletions TablePro/Core/MCP/Protocol/Tools/ExportDataTool.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Foundation
import os
import TableProPluginKit

public struct ExportDataTool: MCPToolImplementation {
public static let name = "export_data"
Expand Down Expand Up @@ -33,7 +34,7 @@ public struct ExportDataTool: MCPToolImplementation {
]),
"max_rows": .object([
"type": .string("integer"),
"description": .string(String(localized: "Maximum rows to export (default 50000)"))
"description": .string(String(localized: "Maximum rows to export. Defaults to the server's configured default row limit and is capped at its maximum row limit."))
])
]),
"required": .array([.string("connection_id"), .string("format")])
Expand Down Expand Up @@ -63,12 +64,13 @@ public struct ExportDataTool: MCPToolImplementation {
let query = MCPArgumentDecoder.optionalString(arguments, key: "query")
let tables = MCPArgumentDecoder.optionalStringArray(arguments, key: "tables")
let outputPath = MCPArgumentDecoder.optionalString(arguments, key: "output_path")
let maxRows = MCPArgumentDecoder.optionalInt(
arguments,
key: "max_rows",
default: 50_000,
clamp: 1...100_000
) ?? 50_000
let settings = await services.settingsProvider()
let maxRows = MCPLimitResolver.resolveMaxRows(
requested: MCPArgumentDecoder.optionalInt(arguments, key: "max_rows"),
settings: settings
)
let timeoutSeconds = MCPLimitResolver.resolveTimeoutSeconds(requested: nil, settings: settings)
let fetchLimit = maxRows + 1

guard Self.allowedFormats.contains(format) else {
throw MCPProtocolError.invalidParams(
Expand Down Expand Up @@ -103,9 +105,10 @@ public struct ExportDataTool: MCPToolImplementation {
queries.append((label: "query", sql: query))
} else if let tables {
let quoteIdentifier = Self.identifierQuoter(for: meta.databaseType)
let autoLimitStyle = Self.autoLimitStyle(for: meta.databaseType)
for table in tables {
let quoted = try Self.quoteQualifiedIdentifier(table, quoter: quoteIdentifier)
let sql = "SELECT * FROM \(quoted) LIMIT \(maxRows)"
let sql = Self.limitedSelectAll(from: quoted, limit: fetchLimit, autoLimitStyle: autoLimitStyle)
try await services.authPolicy.checkSafeModeDialog(
sql: sql,
connectionId: connectionId,
Expand All @@ -118,21 +121,32 @@ public struct ExportDataTool: MCPToolImplementation {

var exportResults: [JsonValue] = []
var totalRowsExported = 0
var anyTruncated = false

for (label, sql) in queries {
let result = try await services.connectionBridge.executeQuery(
connectionId: connectionId,
let result = try await ToolQueryExecutor.executeAndLog(
services: services,
query: sql,
maxRows: maxRows,
timeoutSeconds: 60
connectionId: connectionId,
databaseName: meta.databaseName,
maxRows: fetchLimit,
timeoutSeconds: timeoutSeconds,
principalLabel: context.principal.metadata.label
)

guard let columns = result["columns"]?.arrayValue,
let rows = result["rows"]?.arrayValue
let fetched = result["rows"]?.arrayValue
else {
throw MCPProtocolError.internalError(detail: "Unexpected query result structure")
}

let limited = Self.applyRowLimit(
to: fetched,
maxRows: maxRows,
driverReportedTruncation: result["is_truncated"]?.boolValue ?? false
)
let rows = limited.rows
let isTruncated = limited.isTruncated
let columnNames = columns.compactMap(\.stringValue)
let formatted: String

Expand All @@ -148,11 +162,13 @@ public struct ExportDataTool: MCPToolImplementation {
}

totalRowsExported += rows.count
anyTruncated = anyTruncated || isTruncated

exportResults.append(.object([
"label": .string(label),
"format": .string(format),
"row_count": result["row_count"] ?? .int(0),
"row_count": .int(rows.count),
"is_truncated": .bool(isTruncated),
"data": .string(formatted)
]))
}
Expand All @@ -173,7 +189,8 @@ public struct ExportDataTool: MCPToolImplementation {

let response: JsonValue = .object([
"path": .string(fileURL.path),
"rows_exported": .int(totalRowsExported)
"rows_exported": .int(totalRowsExported),
"is_truncated": .bool(anyTruncated)
])
return .structured(response)
}
Expand All @@ -195,6 +212,31 @@ public struct ExportDataTool: MCPToolImplementation {
}
}

static func applyRowLimit(
to fetched: [JsonValue],
maxRows: Int,
driverReportedTruncation: Bool
) -> (rows: [JsonValue], isTruncated: Bool) {
(Array(fetched.prefix(maxRows)), fetched.count > maxRows || driverReportedTruncation)
}

static func autoLimitStyle(for databaseType: DatabaseType) -> AutoLimitStyle {
(try? resolveSQLDialect(for: databaseType))?.autoLimitStyle ?? .limit
}

static func limitedSelectAll(from quotedTable: String, limit: Int, autoLimitStyle: AutoLimitStyle) -> String {
switch autoLimitStyle {
case .top:
return "SELECT TOP \(limit) * FROM \(quotedTable)"
case .fetchFirst:
return "SELECT * FROM \(quotedTable) FETCH FIRST \(limit) ROWS ONLY"
case .none:
return "SELECT * FROM \(quotedTable)"
default:
return "SELECT * FROM \(quotedTable) LIMIT \(limit)"
}
}

static func identifierQuoter(for databaseType: DatabaseType) -> (String) -> String {
if let dialect = try? resolveSQLDialect(for: databaseType) {
return quoteIdentifierFromDialect(dialect)
Expand Down
4 changes: 2 additions & 2 deletions TablePro/Core/MCP/Protocol/Tools/MCPArgumentDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,8 +42,8 @@ enum MCPArgumentDecoder {
default defaultValue: Int? = nil,
clamp: ClosedRange<Int>? = nil
) -> Int? {
let raw = args[key]?.intValue
guard let raw else { return defaultValue }
let raw = args[key]?.intValue ?? defaultValue
guard let raw else { return nil }
guard let clamp else { return raw }
return min(max(raw, clamp.lowerBound), clamp.upperBound)
}
Expand Down
13 changes: 13 additions & 0 deletions TablePro/Core/MCP/Protocol/Tools/MCPLimitResolver.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import Foundation

enum MCPLimitResolver {
static func resolveMaxRows(requested: Int?, settings: MCPSettings) -> Int {
guard let requested else { return settings.effectiveDefaultRowLimit }
return requested.clamped(to: settings.requestableRowLimitRange)
}

static func resolveTimeoutSeconds(requested: Int?, settings: MCPSettings) -> Int {
guard let requested else { return settings.validatedQueryTimeoutSeconds }
return requested.clamped(to: SettingsValidationRules.mcpQueryTimeoutRange)
}
}
14 changes: 14 additions & 0 deletions TablePro/Core/MCP/Protocol/Tools/MCPToolServices.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,23 @@ import Foundation
public struct MCPToolServices: Sendable {
public let connectionBridge: MCPConnectionBridge
public let authPolicy: MCPAuthPolicy
let settingsProvider: @Sendable () async -> MCPSettings

public init(connectionBridge: MCPConnectionBridge, authPolicy: MCPAuthPolicy) {
self.init(
connectionBridge: connectionBridge,
authPolicy: authPolicy,
settingsProvider: { await MainActor.run { AppSettingsManager.shared.mcp } }
)
}

init(
connectionBridge: MCPConnectionBridge,
authPolicy: MCPAuthPolicy,
settingsProvider: @escaping @Sendable () async -> MCPSettings
) {
self.connectionBridge = connectionBridge
self.authPolicy = authPolicy
self.settingsProvider = settingsProvider
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -95,4 +95,7 @@ enum SettingsValidationRules {
static let defaultPageSizeRange = 10...100_000
static let queryResultRowCapRange: ClosedRange<Int> = 100...500_000
static let minNonNegative = 0

static let mcpRowLimitRange: ClosedRange<Int> = 1...500_000
static let mcpQueryTimeoutRange: ClosedRange<Int> = 1...300
}
13 changes: 10 additions & 3 deletions TablePro/Core/Storage/AppSettingsManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,21 @@ final class AppSettingsManager {
var mcp: MCPSettings {
didSet {
guard !isValidating else { return }
var validated = mcp
validated.maxRowLimit = mcp.validatedMaxRowLimit
validated.defaultRowLimit = mcp.validatedDefaultRowLimit
validated.queryTimeoutSeconds = mcp.validatedQueryTimeoutSeconds
if validated.allowRemoteConnections, !validated.requireAuthentication {
validated.requireAuthentication = true
}

if mcp.allowRemoteConnections, !mcp.requireAuthentication {
if validated != mcp {
isValidating = true
mcp.requireAuthentication = true
mcp = validated
isValidating = false
}

storage.saveMCP(mcp)
storage.saveMCP(validated)
syncTracker.markDirty(.settings, id: "mcp")
let enabledChanged = mcp.enabled != oldValue.enabled
let portChanged = mcp.port != oldValue.port
Expand Down
24 changes: 24 additions & 0 deletions TablePro/Models/Settings/MCPSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,29 @@ struct MCPSettings: Codable, Equatable {
logQueriesInHistory = try container.decodeIfPresent(Bool.self, forKey: .logQueriesInHistory) ?? true
requireAuthentication = try container.decodeIfPresent(Bool.self, forKey: .requireAuthentication) ?? true
allowRemoteConnections = try container.decodeIfPresent(Bool.self, forKey: .allowRemoteConnections) ?? false

maxRowLimit = validatedMaxRowLimit
defaultRowLimit = validatedDefaultRowLimit
queryTimeoutSeconds = validatedQueryTimeoutSeconds
}

var validatedMaxRowLimit: Int {
maxRowLimit.clamped(to: SettingsValidationRules.mcpRowLimitRange)
}

var validatedDefaultRowLimit: Int {
defaultRowLimit.clamped(to: SettingsValidationRules.mcpRowLimitRange)
}

var validatedQueryTimeoutSeconds: Int {
queryTimeoutSeconds.clamped(to: SettingsValidationRules.mcpQueryTimeoutRange)
}

var effectiveDefaultRowLimit: Int {
min(validatedDefaultRowLimit, validatedMaxRowLimit)
}

var requestableRowLimitRange: ClosedRange<Int> {
SettingsValidationRules.mcpRowLimitRange.lowerBound...validatedMaxRowLimit
}
}
Loading
Loading