-
Notifications
You must be signed in to change notification settings - Fork 7
feat: BrainBar Swift daemon — MCP over Unix socket #84
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b8296a3
d24b27f
edfe899
cee3c56
ff33abc
e2fd0cf
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,23 @@ | ||
| { | ||
| "_comment": "Run: ~/Gits/orchestrator/scripts/mcp-setup.sh ~/Gits/brainlayer", | ||
| "_comment2": "brainlayer-socat: BrainBar daemon on Unix socket (replaces 9 Python processes)", | ||
| "mcpServers": { | ||
| "brainlayer": { | ||
| "command": "socat", | ||
| "args": ["STDIO", "UNIX-CONNECT:/tmp/brainbar.sock"] | ||
| }, | ||
| "brainlayer-legacy": { | ||
| "_comment": "Fallback: original Python MCP server (remove after BrainBar is stable)", | ||
| "command": "brainlayer-mcp" | ||
| }, | ||
| "voicelayer": { | ||
| "command": "voicelayer-mcp" | ||
| }, | ||
| "cmux": { | ||
| "command": "node", | ||
| "args": [ | ||
| "/Users/etanheyman/Gits/orchestrator/tools/cmux-mcp/dist/index.js" | ||
| ] | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| .build/ | ||
| .swiftpm/ | ||
| *.xcodeproj/ | ||
| DerivedData/ |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| // swift-tools-version: 6.0 | ||
| import PackageDescription | ||
|
|
||
| let package = Package( | ||
| name: "BrainBar", | ||
| platforms: [ | ||
| .macOS(.v14), | ||
| ], | ||
| targets: [ | ||
| .executableTarget( | ||
| name: "BrainBar", | ||
| path: "Sources/BrainBar", | ||
| linkerSettings: [ | ||
| .linkedLibrary("sqlite3"), | ||
| ] | ||
| ), | ||
| .testTarget( | ||
| name: "BrainBarTests", | ||
| dependencies: ["BrainBar"], | ||
| path: "Tests/BrainBarTests" | ||
| ), | ||
| ] | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,58 @@ | ||
| // BrainBarApp.swift — Entry point for BrainBar menu bar daemon. | ||
| // | ||
| // Menu bar app (no Dock icon) that owns the BrainLayer SQLite database | ||
| // and serves MCP tools over /tmp/brainbar.sock. | ||
|
|
||
| import AppKit | ||
| import SwiftUI | ||
|
|
||
| // MARK: - App Delegate | ||
|
|
||
| final class AppDelegate: NSObject, NSApplicationDelegate { | ||
| private var server: BrainBarServer? | ||
|
|
||
| func applicationDidFinishLaunching(_ notification: Notification) { | ||
| NSApp.setActivationPolicy(.accessory) | ||
|
|
||
| let srv = BrainBarServer() | ||
| server = srv | ||
| srv.start() | ||
| } | ||
|
|
||
| func applicationWillTerminate(_ notification: Notification) { | ||
| server?.stop() | ||
| } | ||
|
|
||
| func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { | ||
| false | ||
| } | ||
| } | ||
|
|
||
| // MARK: - SwiftUI App entry point | ||
|
|
||
| @main | ||
| struct BrainBarApp: App { | ||
| @NSApplicationDelegateAdaptor(AppDelegate.self) var appDelegate | ||
|
|
||
| var body: some Scene { | ||
| MenuBarExtra("BrainBar", systemImage: "brain.head.profile") { | ||
| VStack(alignment: .leading, spacing: 6) { | ||
| Text("BrainBar") | ||
| .font(.system(.caption, weight: .bold)) | ||
| Text("Memory daemon active") | ||
| .font(.system(.caption2)) | ||
| .foregroundStyle(.secondary) | ||
| Divider() | ||
| Button("Quit BrainBar") { | ||
| NSApplication.shared.terminate(nil) | ||
| } | ||
| .keyboardShortcut("q") | ||
| } | ||
| .padding(8) | ||
| } | ||
|
|
||
| Settings { | ||
| EmptyView() | ||
| } | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,199 @@ | ||
| // BrainBarServer.swift — Integrated socket server + MCP router + database. | ||
| // | ||
| // Owns: | ||
| // - Unix domain socket on /tmp/brainbar.sock | ||
| // - MCP Content-Length framing parser | ||
| // - JSON-RPC router | ||
| // - SQLite database (single-writer) | ||
|
|
||
| import Foundation | ||
|
|
||
| final class BrainBarServer: @unchecked Sendable { | ||
| private let socketPath: String | ||
| private let dbPath: String | ||
| private let queue = DispatchQueue(label: "com.brainlayer.brainbar.server", qos: .userInitiated) | ||
| private var listenFD: Int32 = -1 | ||
| private var listenSource: DispatchSourceRead? | ||
| private var clients: [Int32: ClientState] = [:] | ||
| private var router: MCPRouter! | ||
| private var database: BrainDatabase! | ||
|
|
||
| struct ClientState { | ||
| var source: DispatchSourceRead | ||
| var framing: MCPFraming | ||
| } | ||
|
|
||
| init(socketPath: String = "/tmp/brainbar.sock", dbPath: String? = nil) { | ||
| self.socketPath = socketPath | ||
| self.dbPath = dbPath ?? Self.defaultDBPath() | ||
| } | ||
|
|
||
| static func defaultDBPath() -> String { | ||
| let home = FileManager.default.homeDirectoryForCurrentUser.path | ||
| return "\(home)/.local/share/brainlayer/brainlayer.db" | ||
| } | ||
|
|
||
| func start() { | ||
| queue.async { [weak self] in | ||
| self?.startOnQueue() | ||
| } | ||
|
Comment on lines
+36
to
+39
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't let startup fail silently. Lines 36-39 detach startup from launch, and the failure paths in Lines 58-60 and 79-92 only log and return. Line 50 also cannot surface a database-open failure back to the caller, so the menu bar app can finish launching even when the daemon never became ready. Also applies to: 48-52, 58-60, 79-92 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| func stop() { | ||
| queue.sync { | ||
| self.cleanup() | ||
| } | ||
| } | ||
|
|
||
| private func startOnQueue() { | ||
| // Initialize database and router | ||
| database = BrainDatabase(path: dbPath) | ||
| router = MCPRouter() | ||
| router.setDatabase(database) | ||
|
|
||
| // Clean up stale socket | ||
| unlink(socketPath) | ||
|
|
||
| let fd = socket(AF_UNIX, SOCK_STREAM, 0) | ||
| guard fd >= 0 else { | ||
| NSLog("[BrainBar] Failed to create socket: errno %d", errno) | ||
| return | ||
| } | ||
|
|
||
| var addr = sockaddr_un() | ||
| addr.sun_family = sa_family_t(AF_UNIX) | ||
| let pathBytes = socketPath.utf8CString | ||
| guard pathBytes.count <= MemoryLayout.size(ofValue: addr.sun_path) else { | ||
| NSLog("[BrainBar] Socket path too long (%d > %d): %@", | ||
| pathBytes.count, MemoryLayout.size(ofValue: addr.sun_path), socketPath) | ||
| close(fd) | ||
| return | ||
| } | ||
| withUnsafeMutablePointer(to: &addr.sun_path) { ptr in | ||
| ptr.withMemoryRebound(to: CChar.self, capacity: pathBytes.count) { dest in | ||
| pathBytes.withUnsafeBufferPointer { src in | ||
| _ = memcpy(dest, src.baseAddress!, src.count) | ||
| } | ||
| } | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| let bindResult = withUnsafePointer(to: &addr) { addrPtr in | ||
| addrPtr.withMemoryRebound(to: sockaddr.self, capacity: 1) { ptr in | ||
| bind(fd, ptr, socklen_t(MemoryLayout<sockaddr_un>.size)) | ||
| } | ||
| } | ||
| guard bindResult == 0 else { | ||
| NSLog("[BrainBar] Failed to bind: errno %d", errno) | ||
| close(fd) | ||
| return | ||
| } | ||
|
|
||
| chmod(socketPath, 0o600) | ||
|
|
||
| guard listen(fd, 16) == 0 else { | ||
| NSLog("[BrainBar] Failed to listen: errno %d", errno) | ||
| close(fd) | ||
| unlink(socketPath) | ||
| return | ||
| } | ||
|
|
||
| listenFD = fd | ||
|
|
||
| let source = DispatchSource.makeReadSource(fileDescriptor: fd, queue: queue) | ||
| source.setEventHandler { [weak self] in | ||
| self?.acceptClient() | ||
| } | ||
| source.setCancelHandler { [weak self] in | ||
| guard let self else { return } | ||
| close(fd) | ||
| listenFD = -1 | ||
| } | ||
| source.resume() | ||
| listenSource = source | ||
|
|
||
| NSLog("[BrainBar] Server listening on %@", socketPath) | ||
| } | ||
|
|
||
| private func acceptClient() { | ||
| let clientFD = accept(listenFD, nil, nil) | ||
| guard clientFD >= 0 else { return } | ||
|
|
||
| let flags = fcntl(clientFD, F_GETFL) | ||
| _ = fcntl(clientFD, F_SETFL, flags | O_NONBLOCK) | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| var nosigpipe: Int32 = 1 | ||
| setsockopt(clientFD, SOL_SOCKET, SO_NOSIGPIPE, &nosigpipe, socklen_t(MemoryLayout<Int32>.size)) | ||
|
|
||
| let readSource = DispatchSource.makeReadSource(fileDescriptor: clientFD, queue: queue) | ||
| readSource.setEventHandler { [weak self] in | ||
| self?.readFromClient(fd: clientFD) | ||
| } | ||
| readSource.setCancelHandler { | ||
| close(clientFD) | ||
| } | ||
| readSource.resume() | ||
|
|
||
| clients[clientFD] = ClientState(source: readSource, framing: MCPFraming()) | ||
| NSLog("[BrainBar] Client connected (fd: %d)", clientFD) | ||
| } | ||
|
|
||
| private func readFromClient(fd: Int32) { | ||
| var buf = [UInt8](repeating: 0, count: 65536) | ||
| let n = read(fd, &buf, buf.count) | ||
|
|
||
| if n <= 0 { | ||
| if n == -1, errno == EAGAIN || errno == EWOULDBLOCK || errno == EINTR { | ||
| return | ||
| } | ||
| clients[fd]?.source.cancel() | ||
| clients.removeValue(forKey: fd) | ||
| return | ||
| } | ||
|
|
||
| guard var state = clients[fd] else { return } | ||
| state.framing.append(Data(buf[0..<n])) | ||
|
|
||
| let messages = state.framing.extractMessages() | ||
| for msg in messages { | ||
| let response = router.handle(msg) | ||
| if !response.isEmpty { | ||
| sendResponse(fd: fd, response: response) | ||
| } | ||
| } | ||
|
|
||
| clients[fd] = state | ||
| } | ||
|
|
||
| private func sendResponse(fd: Int32, response: [String: Any]) { | ||
| guard let framed = try? MCPFraming.encode(response) else { return } | ||
| framed.withUnsafeBytes { ptr in | ||
| var totalWritten = 0 | ||
| while totalWritten < framed.count { | ||
| let n = write(fd, ptr.baseAddress!.advanced(by: totalWritten), framed.count - totalWritten) | ||
| if n < 0 { | ||
| if errno == EAGAIN || errno == EWOULDBLOCK { | ||
| // Kernel buffer full — brief retry | ||
| usleep(1000) // 1ms | ||
| continue | ||
| } | ||
| break // Real error | ||
| } | ||
| if n == 0 { break } // EOF | ||
| totalWritten += n | ||
| } | ||
|
Comment on lines
+167
to
+183
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Don't spin on a blocked client socket on the server queue. Lines 174-177 retry forever with 🤖 Prompt for AI Agents |
||
| } | ||
| } | ||
|
|
||
| private func cleanup() { | ||
| listenSource?.cancel() | ||
| listenSource = nil | ||
| for (_, state) in clients { | ||
| state.source.cancel() | ||
| } | ||
| clients.removeAll() | ||
| if listenFD >= 0 { listenFD = -1 } | ||
| unlink(socketPath) | ||
| database?.close() | ||
| NSLog("[BrainBar] Server stopped") | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove hardcoded user-specific path from example file.
The
cmuxentry contains a hardcoded absolute path (/Users/etanheyman/...) that won't work for other developers. Example files should use placeholder paths or environment variables.💡 Suggested fix
"cmux": { "command": "node", "args": [ - "/Users/etanheyman/Gits/orchestrator/tools/cmux-mcp/dist/index.js" + "${ORCHESTRATOR_PATH}/tools/cmux-mcp/dist/index.js" ] }Or use a comment placeholder like
"/path/to/orchestrator/tools/cmux-mcp/dist/index.js".📝 Committable suggestion
🤖 Prompt for AI Agents