Skip to content

Commit f8d8337

Browse files
committed
feat: add MCP connected clients list and menu status item
1 parent b32cac2 commit f8d8337

5 files changed

Lines changed: 132 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111

1212
- 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
1314
- Import connections from TablePlus, Sequel Ace, and DBeaver with one-click migration
1415
- Embedded database CLI terminal (View > Open Terminal or Ctrl+Cmd+`) auto-launches mysql, psql, redis-cli, etc. for the active connection
1516
- Structure tab: modify existing tables (add, modify, drop columns, indexes, foreign keys, primary keys)

TablePro/Core/MCP/MCPServer.swift

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,14 @@ import Network
88
import os
99

1010
actor MCPServer {
11+
struct SessionSnapshot: Sendable, Identifiable {
12+
let id: String
13+
let clientName: String
14+
let clientVersion: String?
15+
let connectedSince: Date
16+
let lastActivityAt: Date
17+
}
18+
1119
private static let logger = Logger(subsystem: "com.TablePro", category: "MCPServer")
1220

1321
private static let maxSessions = 10
@@ -109,6 +117,26 @@ actor MCPServer {
109117
sessions.count
110118
}
111119

120+
func sessionSnapshots() async -> [SessionSnapshot] {
121+
let now = ContinuousClock.now
122+
var snapshots: [SessionSnapshot] = []
123+
for (_, session) in sessions {
124+
let info = await session.clientInfo
125+
let created = await session.createdAt
126+
let lastActive = await session.lastActivityAt
127+
let connectedElapsed = now - created
128+
let activeElapsed = now - lastActive
129+
snapshots.append(SessionSnapshot(
130+
id: session.id,
131+
clientName: info?.name ?? String(localized: "Unknown"),
132+
clientVersion: info?.version,
133+
connectedSince: Date.now - TimeInterval(connectedElapsed.components.seconds),
134+
lastActivityAt: Date.now - TimeInterval(activeElapsed.components.seconds)
135+
))
136+
}
137+
return snapshots
138+
}
139+
112140
// MARK: - Listener State
113141

114142
private func handleListenerState(_ state: NWListener.State, listener: NWListener) {

TablePro/Core/MCP/MCPServerManager.swift

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,9 @@ final class MCPServerManager {
2121
static let shared = MCPServerManager()
2222

2323
private(set) var state: MCPServerState = .stopped
24+
private(set) var connectedClients: [MCPServer.SessionSnapshot] = []
2425
private var server: MCPServer?
26+
private var clientRefreshTask: Task<Void, Never>?
2527

2628
var isRunning: Bool {
2729
if case .running = state { return true } else { return false }
@@ -67,6 +69,7 @@ final class MCPServerManager {
6769

6870
do {
6971
try await newServer.start(port: port)
72+
startClientRefresh()
7073
} catch {
7174
Self.logger.error("Failed to start MCP server: \(error.localizedDescription)")
7275
state = .failed(error.localizedDescription)
@@ -75,6 +78,7 @@ final class MCPServerManager {
7578
}
7679

7780
func stop() async {
81+
stopClientRefresh()
7882
guard let server else { return }
7983
await server.stop()
8084
self.server = nil
@@ -85,4 +89,34 @@ final class MCPServerManager {
8589
await stop()
8690
await start(port: port)
8791
}
92+
93+
func disconnectClient(_ sessionId: String) async {
94+
await server?.removeSession(sessionId)
95+
await refreshClients()
96+
}
97+
98+
// MARK: - Client Refresh
99+
100+
private func startClientRefresh() {
101+
clientRefreshTask = Task { [weak self] in
102+
while !Task.isCancelled {
103+
await self?.refreshClients()
104+
try? await Task.sleep(for: .seconds(5))
105+
}
106+
}
107+
}
108+
109+
private func stopClientRefresh() {
110+
clientRefreshTask?.cancel()
111+
clientRefreshTask = nil
112+
connectedClients = []
113+
}
114+
115+
private func refreshClients() async {
116+
guard let server else {
117+
connectedClients = []
118+
return
119+
}
120+
connectedClients = await server.sessionSnapshots()
121+
}
88122
}

TablePro/TableProApp.swift

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,12 +141,14 @@ struct AppMenuCommands: Commands {
141141
}
142142

143143
var body: some Commands {
144-
// Custom About window + Check for Updates
144+
// Custom About window + Check for Updates + MCP status
145145
CommandGroup(replacing: .appInfo) {
146146
Button(String(localized: "About TablePro")) {
147147
AboutWindowController.shared.showAboutPanel()
148148
}
149149
CheckForUpdatesView(updaterBridge: updaterBridge)
150+
Divider()
151+
MCPServerMenuItem()
150152
}
151153

152154
// MARK: - Keyboard Shortcut Architecture
@@ -667,6 +669,35 @@ struct CheckForUpdatesView: View {
667669
}
668670
}
669671

672+
// MARK: - MCP Server Menu Item
673+
674+
private struct MCPServerMenuItem: View {
675+
@State private var manager = MCPServerManager.shared
676+
677+
var body: some View {
678+
Button(menuTitle) {
679+
NotificationCenter.default.post(name: .openSettingsWindow, object: nil)
680+
}
681+
}
682+
683+
private var menuTitle: String {
684+
switch manager.state {
685+
case .running:
686+
let count = manager.connectedClients.count
687+
if count == 0 {
688+
return String(localized: "MCP Server: Running")
689+
}
690+
return String(format: String(localized: "MCP Server: Running (%d clients)"), count)
691+
case .failed:
692+
return String(localized: "MCP Server: Failed")
693+
case .stopped:
694+
return String(localized: "MCP Server: Stopped")
695+
case .starting:
696+
return String(localized: "MCP Server: Starting...")
697+
}
698+
}
699+
}
700+
670701
// MARK: - Open Window Handler
671702

672703
/// Helper view that listens for window open notifications

TablePro/Views/Settings/MCPSettingsView.swift

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import SwiftUI
77

88
struct MCPSettingsView: View {
99
@Bindable var settingsManager: AppSettingsManager
10+
@State private var manager = MCPServerManager.shared
1011

1112
var body: some View {
1213
Form {
@@ -25,6 +26,7 @@ struct MCPSettingsView: View {
2526
if settingsManager.mcp.enabled {
2627
configurationSection
2728
clientConfigurationSection
29+
connectedClientsSection
2830

2931
Section {
3032
Text("AI access policies are configured per-connection in each connection's settings.")
@@ -108,6 +110,41 @@ struct MCPSettingsView: View {
108110
}
109111
}
110112

113+
// MARK: - Connected Clients
114+
115+
private var connectedClientsSection: some View {
116+
Section("Connected Clients") {
117+
if manager.connectedClients.isEmpty {
118+
Text("No clients connected")
119+
.foregroundStyle(.secondary)
120+
} else {
121+
ForEach(manager.connectedClients) { client in
122+
HStack {
123+
VStack(alignment: .leading, spacing: 2) {
124+
HStack(spacing: 4) {
125+
Text(client.clientName)
126+
if let version = client.clientVersion {
127+
Text(version)
128+
.foregroundStyle(.secondary)
129+
}
130+
}
131+
Text(client.connectedSince, style: .relative)
132+
.font(.caption)
133+
.foregroundStyle(.secondary)
134+
}
135+
Spacer()
136+
Button("Disconnect") {
137+
Task {
138+
await manager.disconnectClient(client.id)
139+
}
140+
}
141+
.controlSize(.small)
142+
}
143+
}
144+
}
145+
}
146+
}
147+
111148
private var configSnippet: String {
112149
"""
113150
{

0 commit comments

Comments
 (0)