Skip to content

Commit d8d1a88

Browse files
authored
feat: built-in MCP server for AI tool integration (#825)
* feat: add MCPSettings model and AppSettings integration * feat: add MCP message types and JSON-RPC protocol types * refactor: extract QueryClassifier from MainContentCoordinator for MCP reuse * feat: add HTTP/1.1 request parser for MCP server * feat: add MCPSession for per-client MCP state tracking * feat: add MCPToolHandler and MCPResourceHandler * feat: add MCP settings UI and AppDelegate integration * feat: add MCPServer, MCPRouter, MCPConnectionBridge, and MCPAuthGuard * docs: add MCP server to CHANGELOG * fix: wire MCP tool handlers, add localization and documentation * fix: number formatting without grouping separators and friendlier port-in-use error * fix: crash in MCP settings didSet from UInt16 trap and @observable data race * fix: validate port range on load and use UInt16(clamping:) in AppDelegate * fix: address all code review findings for MCP server * feat: add MCP connected clients list and menu status item * fix: port change race condition — only restart on enabled/port change, await listener release * fix: final review — stop() race, cancellation tracking, xlsx removal, stale callback guard * feat: redesign MCP client configuration with per-tool setup instructions * fix: handle OAuth discovery probes from Claude Code and use --transport http * refactor: MCP server spec compliance — Origin validation, session HTTP codes, init gate, SSE IDs * docs: update MCP docs transport type, add MCP and Terminal to landing page features
1 parent d862e39 commit d8d1a88

23 files changed

Lines changed: 4055 additions & 78 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Added
1111

12+
- MCP server: built-in Model Context Protocol server lets AI tools (Claude Desktop, Claude Code, Cursor) browse schemas, run queries, and export data through TablePro's connections
13+
- MCP server: connected clients list in Settings and status menu item showing server state
1214
- Import connections from TablePlus, Sequel Ace, and DBeaver with one-click migration
1315
- Embedded database CLI terminal (View > Open Terminal or Ctrl+Cmd+`) auto-launches mysql, psql, redis-cli, etc. for the active connection
1416
- Structure tab: modify existing tables (add, modify, drop columns, indexes, foreign keys, primary keys)

TablePro/AppDelegate.swift

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,12 @@ class AppDelegate: NSObject, NSApplicationDelegate {
108108
SyncCoordinator.shared.start()
109109
LinkedFolderWatcher.shared.start()
110110

111+
if AppSettingsManager.shared.mcp.enabled {
112+
Task {
113+
await MCPServerManager.shared.start(port: UInt16(clamping: AppSettingsManager.shared.mcp.port))
114+
}
115+
}
116+
111117
Task.detached(priority: .background) {
112118
_ = QueryHistoryStorage.shared
113119
}
@@ -205,6 +211,9 @@ class AppDelegate: NSObject, NSApplicationDelegate {
205211
}
206212

207213
func applicationWillTerminate(_ notification: Notification) {
214+
Task {
215+
await MCPServerManager.shared.stop()
216+
}
208217
LinkedFolderWatcher.shared.stop()
209218
SSHTunnelManager.shared.terminateAllProcessesSync()
210219
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
//
2+
// MCPAuthGuard.swift
3+
// TablePro
4+
//
5+
// Enforces AIConnectionPolicy and SafeModeLevel for MCP requests.
6+
//
7+
8+
import AppKit
9+
import Foundation
10+
import os
11+
12+
actor MCPAuthGuard {
13+
private static let logger = Logger(subsystem: "com.TablePro", category: "MCPAuthGuard")
14+
15+
/// Per-session approved connections (for askEachTime policy)
16+
private var sessionApprovals: [String: Set<UUID>] = [:]
17+
18+
// MARK: - Connection Access Check
19+
20+
func checkConnectionAccess(connectionId: UUID, sessionId: String) async throws {
21+
let (policy, connectionName, databaseType) = await MainActor.run {
22+
let conns = ConnectionStorage.shared.loadConnections()
23+
guard let conn = conns.first(where: { $0.id == connectionId }) else {
24+
return (AIConnectionPolicy.never, "", "")
25+
}
26+
let effective = conn.aiPolicy ?? AppSettingsManager.shared.ai.defaultConnectionPolicy
27+
return (effective, conn.name, conn.type.rawValue)
28+
}
29+
30+
switch policy {
31+
case .alwaysAllow:
32+
return
33+
34+
case .never:
35+
throw MCPError.forbidden(
36+
String(localized: "AI access is disabled for this connection")
37+
)
38+
39+
case .askEachTime:
40+
if let approved = sessionApprovals[sessionId], approved.contains(connectionId) {
41+
return
42+
}
43+
44+
let userApproved = try await promptUserApproval(
45+
connectionName: connectionName,
46+
databaseType: databaseType
47+
)
48+
49+
if userApproved {
50+
sessionApprovals[sessionId, default: []].insert(connectionId)
51+
} else {
52+
throw MCPError.forbidden(
53+
String(localized: "User denied MCP access to this connection")
54+
)
55+
}
56+
}
57+
}
58+
59+
// MARK: - Query Permission Check
60+
61+
func checkQueryPermission(
62+
sql: String,
63+
connectionId: UUID,
64+
databaseType: DatabaseType,
65+
safeModeLevel: SafeModeLevel
66+
) async throws {
67+
let isWrite = QueryClassifier.isWriteQuery(sql, databaseType: databaseType)
68+
69+
// SafeModeGuard.checkPermission is @MainActor async; Swift hops automatically
70+
let permission = await SafeModeGuard.checkPermission(
71+
level: safeModeLevel,
72+
isWriteOperation: isWrite,
73+
sql: sql,
74+
operationDescription: String(localized: "MCP query execution"),
75+
window: nil,
76+
databaseType: databaseType
77+
)
78+
79+
if case .blocked(let reason) = permission {
80+
throw MCPError.forbidden(reason)
81+
}
82+
}
83+
84+
// MARK: - Query Logging
85+
86+
func logQuery(
87+
sql: String,
88+
connectionId: UUID,
89+
databaseName: String,
90+
executionTime: TimeInterval,
91+
rowCount: Int,
92+
wasSuccessful: Bool,
93+
errorMessage: String?
94+
) async {
95+
let shouldLog = await MainActor.run {
96+
AppSettingsManager.shared.mcp.logQueriesInHistory
97+
}
98+
guard shouldLog else { return }
99+
100+
let entry = QueryHistoryEntry(
101+
query: sql,
102+
connectionId: connectionId,
103+
databaseName: databaseName,
104+
executionTime: executionTime,
105+
rowCount: rowCount,
106+
wasSuccessful: wasSuccessful,
107+
errorMessage: errorMessage
108+
)
109+
110+
_ = await QueryHistoryStorage.shared.addHistory(entry)
111+
}
112+
113+
// MARK: - User Approval (askEachTime)
114+
115+
private func promptUserApproval(connectionName: String, databaseType: String) async throws -> Bool {
116+
// Use a task group so the actor suspends (freeing it for other requests)
117+
// while the approval dialog is shown on the main thread.
118+
// Race the dialog against a 30-second timeout.
119+
let approvalTask = Task { @MainActor in
120+
NSApp.requestUserAttention(.criticalRequest)
121+
NSApp.activate(ignoringOtherApps: true)
122+
return await AlertHelper.confirmDestructive(
123+
title: String(localized: "MCP Access Request"),
124+
message: String(
125+
format: String(localized: "An MCP client wants to access '%@' (%@). Allow?"),
126+
connectionName,
127+
databaseType
128+
),
129+
confirmButton: String(localized: "Allow"),
130+
cancelButton: String(localized: "Deny"),
131+
window: nil
132+
)
133+
}
134+
135+
let approved = try await withThrowingTaskGroup(of: Bool.self) { group in
136+
group.addTask {
137+
await approvalTask.value
138+
}
139+
group.addTask {
140+
try await Task.sleep(for: .seconds(30))
141+
approvalTask.cancel()
142+
throw MCPError.timeout(
143+
String(localized: "User approval timed out after 30 seconds")
144+
)
145+
}
146+
guard let result = try await group.next() else {
147+
throw MCPError.internalError("No result from approval prompt")
148+
}
149+
approvalTask.cancel()
150+
group.cancelAll()
151+
return result
152+
}
153+
154+
if approved {
155+
return true
156+
}
157+
throw MCPError.forbidden(
158+
String(localized: "User denied MCP access to this connection")
159+
)
160+
}
161+
162+
// MARK: - Session Cleanup
163+
164+
func clearSession(_ sessionId: String) {
165+
sessionApprovals.removeValue(forKey: sessionId)
166+
}
167+
}

0 commit comments

Comments
 (0)