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
24 changes: 21 additions & 3 deletions brain-bar/Sources/BrainBar/BrainBarServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ final class BrainBarServer: @unchecked Sendable {
struct ClientState {
var source: DispatchSourceRead
var framing: MCPFraming
let paletteSession: MCPRouter.PaletteSession
/// Whether this client uses Content-Length framing (LSP-style).
/// false = newline-delimited JSON-RPC (Claude Code v2.1+).
var usesContentLengthFraming: Bool = true
Expand Down Expand Up @@ -441,7 +442,11 @@ final class BrainBarServer: @unchecked Sendable {
}
readSource.resume()

clients[clientFD] = ClientState(source: readSource, framing: MCPFraming())
clients[clientFD] = ClientState(
source: readSource,
framing: MCPFraming(),
paletteSession: router.makePaletteSession()
)
NSLog("[BrainBar] Client connected (fd: %d)", clientFD)
debugLog("CLIENT CONNECTED fd=\(clientFD) (total clients: \(clients.count))")
}
Expand Down Expand Up @@ -493,16 +498,29 @@ final class BrainBarServer: @unchecked Sendable {
}

private func handleMessage(fd: Int32, request: [String: Any]) -> [String: Any] {
let paletteSession = clients[fd]?.paletteSession
if let toolCall = parseToolCall(request) {
switch toolCall.name {
case "brain_subscribe":
guard let paletteSession,
router.isToolExposed(toolCall.name, session: paletteSession) else {
return router.handle(request, session: paletteSession)
}
return handleSubscribeTool(fd: fd, id: request["id"], arguments: toolCall.arguments)
case "brain_unsubscribe":
guard let paletteSession,
router.isToolExposed(toolCall.name, session: paletteSession) else {
return router.handle(request, session: paletteSession)
}
return handleUnsubscribeTool(fd: fd, id: request["id"], arguments: toolCall.arguments)
case "brain_ack":
guard let paletteSession,
router.isToolExposed(toolCall.name, session: paletteSession) else {
return router.handle(request, session: paletteSession)
}
return handleAckTool(id: request["id"], arguments: toolCall.arguments)
default:
let response = router.handle(request)
let response = router.handle(request, session: paletteSession)
if toolCall.name == "brain_store", !isToolError(response) {
publishStoredChunks(response: response, arguments: toolCall.arguments)
}
Expand All @@ -520,7 +538,7 @@ final class BrainBarServer: @unchecked Sendable {
break
}
}
return router.handle(request)
return router.handle(request, session: paletteSession)
}

@discardableResult
Expand Down
198 changes: 177 additions & 21 deletions brain-bar/Sources/BrainBar/MCPRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,32 @@
import Foundation

final class MCPRouter: @unchecked Sendable {
private enum ToolProfile {
case core
case full
}

private static let profileEnvironmentKey = "BRAINLAYER_MCP_PROFILE"
private static let coreToolNames: Set<String> = [
"brain_search",
"brain_store",
"brain_recall",
"brain_expand",
]
private static let coreToolDescriptions: [String: String] = [
"brain_search": "Search memory.",
"brain_store": "Store memory.",
"brain_recall": "Recall context.",
"brain_expand": "Expand chunk.",
]
nonisolated(unsafe) private static let expandPaletteToolDefinition: [String: Any] = [
"name": "expand_palette",
"description": "Expose all tools.",
"inputSchema": [
"type": "object",
] as [String: Any],
]

// Keep contested MCP writes interactive: make one short attempt, then queue
// for replay so brain_store returns well under the 1s prompt-queue budget.
// Longer contention handling belongs in the deferred single-writer/backpressure
Expand All @@ -29,6 +55,25 @@ final class MCPRouter: @unchecked Sendable {
private static let pendingStoreDrainInitialDelay: TimeInterval = 0.25
private static let pendingStoreDrainMaxDelay: TimeInterval = 30.0

final class PaletteSession: @unchecked Sendable {
private let lock = NSLock()
private var expanded = false

func isExpanded() -> Bool {
lock.lock()
defer { lock.unlock() }
return expanded
}

func expand() -> Bool {
lock.lock()
defer { lock.unlock() }
guard !expanded else { return false }
expanded = true
return true
}
}

private struct ToolOutput {
let text: String
let metadata: [String: Any]
Expand Down Expand Up @@ -94,6 +139,8 @@ final class MCPRouter: @unchecked Sendable {
private let hybridSearchClient: HybridSearchClientProtocol?
private let dbPath: String?
private let hybridSearchBudget: TimeInterval
private let toolProfile: ToolProfile
private let defaultPaletteSession = PaletteSession()
let entityCache = EntityCache()
private static let defaultStringMaxLength = 256
private static let defaultStringArrayMaxItems = 100
Expand Down Expand Up @@ -121,15 +168,108 @@ final class MCPRouter: @unchecked Sendable {
]

init(
profile: String? = nil,
hybridSearchClient: HybridSearchClientProtocol? = nil,
hybridSearchBudget: TimeInterval = 0.8,
dbPath: String? = nil
) {
self.toolProfile = Self.resolveToolProfile(profile)
self.hybridSearchClient = hybridSearchClient
self.hybridSearchBudget = max(0.001, hybridSearchBudget)
self.dbPath = dbPath
}

private static func resolveToolProfile(_ explicitProfile: String?) -> ToolProfile {
let environmentProfile = ProcessInfo.processInfo.environment[profileEnvironmentKey]
let rawProfile = explicitProfile ?? environmentProfile
let normalized = rawProfile?
.trimmingCharacters(in: .whitespacesAndNewlines)
.lowercased() ?? ""

switch normalized {
case "full", "operator":
return .full
case "", "core":
return .core
default:
if explicitProfile == nil, let rawProfile = environmentProfile {
let message = "BrainBar: unknown \(profileEnvironmentKey)=\(rawProfile); using core profile\n"
FileHandle.standardError.write(Data(message.utf8))
}
return .core
}
}

func makePaletteSession() -> PaletteSession {
PaletteSession()
}

private func exposedToolDefinitions(for session: PaletteSession) -> [[String: Any]] {
if toolProfile == .full || session.isExpanded() {
return Self.toolDefinitions
}

let coreDefinitions = Self.toolDefinitions.filter { definition in
guard let name = definition["name"] as? String else { return false }
return Self.coreToolNames.contains(name)
}.map(Self.compactCoreToolDefinition)
return coreDefinitions + [Self.expandPaletteToolDefinition]
}

func isToolExposed(_ name: String, session: PaletteSession) -> Bool {
exposedToolDefinitions(for: session).contains { ($0["name"] as? String) == name }
}

private static func compactCoreToolDefinition(_ definition: [String: Any]) -> [String: Any] {
guard let name = definition["name"] as? String else { return definition }

var compact: [String: Any] = ["name": name]
compact["description"] = coreToolDescriptions[name]
if let inputSchema = definition["inputSchema"] as? [String: Any] {
compact["inputSchema"] = removingDescriptions(from: inputSchema)
}
return compact
}

private static func removingDescriptions(from value: Any) -> Any {
if let dictionary = value as? [String: Any] {
return dictionary.reduce(into: [String: Any]()) { result, entry in
guard entry.key != "description" else { return }
result[entry.key] = removingDescriptions(from: entry.value)
}
}
if let array = value as? [Any] {
return array.map(removingDescriptions(from:))
}
return value
}
Comment thread
EtanHey marked this conversation as resolved.

private func expandPalette(session: PaletteSession) -> ToolOutput {
guard session.expand() else {
return ToolOutput(
text: "BrainLayer tool palette is already expanded.",
metadata: [
"expanded": false,
"already_expanded": true,
"registered_tools": [String](),
]
)
}

let deferredNames = Self.toolDefinitions.compactMap { definition -> String? in
guard let name = definition["name"] as? String else { return nil }
return Self.coreToolNames.contains(name) ? nil : name
}
return ToolOutput(
text: "Expanded BrainLayer tool palette.",
metadata: [
"expanded": true,
"already_expanded": false,
"registered_tools": deferredNames,
]
)
}

/// Inject database for tool handlers + load entity cache.
func setDatabase(_ db: BrainDatabase) {
setDatabases(write: db, read: db)
Expand Down Expand Up @@ -160,7 +300,8 @@ final class MCPRouter: @unchecked Sendable {

/// Handle a parsed JSON-RPC request and return a response.
/// Returns empty dict for notifications (no id).
func handle(_ request: [String: Any]) -> [String: Any] {
func handle(_ request: [String: Any], session: PaletteSession? = nil) -> [String: Any] {
let paletteSession = session ?? defaultPaletteSession
guard let method = request["method"] as? String else {
return jsonRPCError(id: request["id"], code: -32600, message: "Invalid request: missing method")
}
Expand All @@ -181,9 +322,13 @@ final class MCPRouter: @unchecked Sendable {
// If a client sends this with an id, ack it so it doesn't hang.
return jsonRPCResult(id: id, result: [:] as [String: Any])
case "tools/list":
return handleToolsList(id: id)
return handleToolsList(id: id, session: paletteSession)
case "tools/call":
return handleToolsCall(id: id, params: request["params"] as? [String: Any] ?? [:])
return handleToolsCall(
id: id,
params: request["params"] as? [String: Any] ?? [:],
session: paletteSession
)
case "resources/list":
return handleResourcesList(id: id)
case "prompts/list":
Expand All @@ -204,7 +349,7 @@ final class MCPRouter: @unchecked Sendable {
"result": [
"protocolVersion": "2024-11-05",
"capabilities": [
"tools": ["listChanged": false],
"tools": ["listChanged": true],
"experimental": [
"claude/channel": [:] as [String: Any]
]
Expand All @@ -219,12 +364,12 @@ final class MCPRouter: @unchecked Sendable {

// MARK: - tools/list

private func handleToolsList(id: Any) -> [String: Any] {
private func handleToolsList(id: Any, session: PaletteSession) -> [String: Any] {
return [
"jsonrpc": "2.0",
"id": id,
"result": [
"tools": Self.toolDefinitions
"tools": exposedToolDefinitions(for: session)
]
]
}
Expand All @@ -236,35 +381,30 @@ final class MCPRouter: @unchecked Sendable {

// MARK: - tools/call

private func handleToolsCall(id: Any, params: [String: Any]) -> [String: Any] {
private func handleToolsCall(id: Any, params: [String: Any], session: PaletteSession) -> [String: Any] {
guard let toolName = params["name"] as? String else {
return jsonRPCError(id: id, code: -32602, message: "Missing tool name")
}

let arguments = params["arguments"] as? [String: Any] ?? [:]

if toolName == "expand_palette" {
guard toolProfile == .core else {
return jsonRPCError(id: id, code: -32601, message: "Unknown tool: \(toolName)")
}
return toolCallResult(id: id, output: expandPalette(session: session))
}

// Check tool exists
guard Self.toolDefinitions.contains(where: { ($0["name"] as? String) == toolName }) else {
guard isToolExposed(toolName, session: session) else {
return jsonRPCError(id: id, code: -32601, message: "Unknown tool: \(toolName)")
}

// Dispatch to handler
do {
try Self.validate(arguments: arguments, for: toolName)
let output = try dispatchTool(name: toolName, arguments: arguments)
var result: [String: Any] = [
"content": [
["type": "text", "text": output.text]
]
]
for (key, value) in output.metadata {
result[key] = value
}
return [
"jsonrpc": "2.0",
"id": id,
"result": result
]
return toolCallResult(id: id, output: output)
} catch {
return [
"jsonrpc": "2.0",
Expand All @@ -279,6 +419,22 @@ final class MCPRouter: @unchecked Sendable {
}
}

private func toolCallResult(id: Any, output: ToolOutput) -> [String: Any] {
var result: [String: Any] = [
"content": [
["type": "text", "text": output.text]
]
]
for (key, value) in output.metadata {
result[key] = value
}
return [
"jsonrpc": "2.0",
"id": id,
"result": result,
]
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

private func dispatchTool(name: String, arguments: [String: Any]) throws -> ToolOutput {
switch name {
case "brain_search":
Expand Down
Loading
Loading