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
35 changes: 34 additions & 1 deletion brain-bar/Sources/BrainBar/BrainBarServer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ final class BrainBarServer: @unchecked Sendable {
framed = data
} else {
// Newline-delimited JSON-RPC (Claude Code v2.1+ / MCP 2025-11-25)
guard let jsonData = try? MCPFraming.encodeJSONResponse(response) else { return false }
guard let jsonData = Self.encodeRawJSONResponse(response) else { return false }
var data = jsonData
data.append(0x0A) // trailing \n
framed = data
Expand Down Expand Up @@ -439,6 +439,39 @@ final class BrainBarServer: @unchecked Sendable {
}
}

private static let claudeExtensionRawChunkLimit = 8192

private static func encodeRawJSONResponse(_ response: [String: Any]) -> Data? {
guard let jsonData = try? MCPFraming.encodeJSONResponse(response) else { return nil }
guard jsonData.count >= claudeExtensionRawChunkLimit else { return jsonData }
guard let compacted = compactRawJSONResponseIfNeeded(response),
let compactData = try? MCPFraming.encodeJSONResponse(compacted) else {
return jsonData
}
return compactData
}

private static func compactRawJSONResponseIfNeeded(_ response: [String: Any]) -> [String: Any]? {
guard let result = response["result"] as? [String: Any],
let tools = result["tools"] as? [[String: Any]] else {
return nil
}

// Claude Desktop's MCPB utility process currently parses raw extension
// stdout in 8192-byte chunks. Raw newline transport omits optional tool
// annotations; Content-Length transport keeps the canonical tools/list.
var compactResult = result
compactResult["tools"] = tools.map { tool in
var compact = tool
compact.removeValue(forKey: "annotations")
Comment thread
EtanHey marked this conversation as resolved.
return compact
}

var compactResponse = response
compactResponse["result"] = compactResult
return compactResponse
}

private func disconnectClient(fd: Int32) {
if let agentID = clients[fd]?.agentID {
try? database?.markSubscriberDisconnected(agentID: agentID)
Expand Down
27 changes: 22 additions & 5 deletions brain-bar/Tests/BrainBarTests/MCPRouterTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,25 @@ final class MCPRouterTests: XCTestCase {
)
}

func testToolsListPreservesCanonicalAnnotations() throws {
let router = MCPRouter()
let response = router.handle([
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
])

let tools = (response["result"] as? [String: Any])?["tools"] as? [[String: Any]] ?? []

XCTAssertEqual(tools.count, 16)
for tool in tools {
XCTAssertNotNil(
tool["annotations"],
"\(tool["name"] ?? "unknown") should keep annotations in the canonical tools/list response"
)
}
}

func testEachToolHasInputSchema() throws {
let router = MCPRouter()
let request: [String: Any] = [
Expand All @@ -178,13 +197,11 @@ final class MCPRouterTests: XCTestCase {

func testEachToolHasExpectedAnnotations() throws {
let router = MCPRouter()
let request: [String: Any] = [
let response = router.handle([
"jsonrpc": "2.0",
"id": 12,
"id": 3,
"method": "tools/list",
]

let response = router.handle(request)
])
let tools = (response["result"] as? [String: Any])?["tools"] as? [[String: Any]] ?? []
let toolsByName = Dictionary(
uniqueKeysWithValues: tools.compactMap { tool -> (String, [String: Any])? in
Expand Down
111 changes: 80 additions & 31 deletions brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -93,39 +93,54 @@ final class SocketIntegrationTests: XCTestCase {
XCTAssertNotNil(tools)
XCTAssertEqual(tools?.count, 16)

let toolsByName = Dictionary(
uniqueKeysWithValues: (tools ?? []).compactMap { tool -> (String, [String: Any])? in
guard let name = tool["name"] as? String else { return nil }
return (name, tool)
}
)
let encodedResponse = try MCPFraming.encodeJSONResponse(response)
XCTAssertGreaterThan(encodedResponse.count, 8192)

for tool in tools ?? [] {
XCTAssertNotNil(
tool["annotations"],
"\(tool["name"] ?? "unknown") should keep annotations over framed socket transport"
)
}
}

let expected: [String: (readOnly: Bool, destructive: Bool, idempotent: Bool, openWorld: Bool)] = [
"brain_search": (true, false, true, false),
"brain_store": (false, false, false, false),
"brain_get_person": (true, false, true, false),
"brain_recall": (true, false, true, false),
"brain_entity": (true, false, true, false),
"brain_digest": (false, false, false, false),
"brain_update": (false, false, true, false),
"brain_expand": (true, false, true, false),
"brain_tags": (true, false, true, false),
"brain_supersede": (false, true, false, false),
"brain_archive": (false, true, false, false),
"brain_enrich": (false, false, false, false),
"brain_subscribe": (false, false, false, false),
"brain_unsubscribe": (false, false, true, false),
"brain_ack": (false, false, true, false),
"brain_maintenance_rebuild_trigram": (false, false, true, false),
]
func testRawLineToolsListCompactsForClaudeExtensionLimit() throws {
let fd = try connectClient()
defer { close(fd) }

for (name, taxonomy) in expected {
let annotations = toolsByName[name]?["annotations"] as? [String: Any]
XCTAssertNotNil(annotations, "\(name) must expose MCP tool annotations over socket transport")
XCTAssertEqual(annotations?["readOnlyHint"] as? Bool, taxonomy.readOnly, "\(name) readOnlyHint mismatch")
XCTAssertEqual(annotations?["destructiveHint"] as? Bool, taxonomy.destructive, "\(name) destructiveHint mismatch")
XCTAssertEqual(annotations?["idempotentHint"] as? Bool, taxonomy.idempotent, "\(name) idempotentHint mismatch")
XCTAssertEqual(annotations?["openWorldHint"] as? Bool, taxonomy.openWorld, "\(name) openWorldHint mismatch")
try sendRawLineJSON(on: fd, object: [
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": [
"protocolVersion": "2024-11-05",
"capabilities": [:] as [String: Any],
"clientInfo": ["name": "claude-extension-test", "version": "1.0"]
]
])
_ = try readRawLineJSONData(fd: fd)

try sendRawLineJSON(on: fd, object: [
"jsonrpc": "2.0",
"id": 2,
"method": "tools/list",
])

let line = try readRawLineJSONData(fd: fd)
let response = try JSONSerialization.jsonObject(with: line) as? [String: Any] ?? [:]
let tools = (response["result"] as? [String: Any])?["tools"] as? [[String: Any]]

XCTAssertLessThan(
line.count,
8192,
"Claude Desktop's MCPB utility process parses raw extension stdout in 8192-byte chunks"
)
XCTAssertEqual(tools?.count, 16)
for tool in tools ?? [] {
XCTAssertNil(
tool["annotations"],
"\(tool["name"] ?? "unknown") should omit optional annotations over raw newline transport"
)
}
}

Expand Down Expand Up @@ -782,6 +797,40 @@ final class SocketIntegrationTests: XCTestCase {
}
}

private func sendRawLineJSON(on fd: Int32, object: [String: Any]) throws {
var data = try JSONSerialization.data(withJSONObject: object)
data.append(0x0A)
let sent = data.withUnsafeBytes { ptr in
write(fd, ptr.baseAddress!, data.count)
}
guard sent == data.count else {
throw NSError(domain: "test", code: 3, userInfo: [NSLocalizedDescriptionKey: "raw write() incomplete"])
}
}

private func readRawLineJSONData(fd: Int32, timeout: TimeInterval = 5.0) throws -> Data {
var buffer = Data()
var readBuf = [UInt8](repeating: 0, count: 65536)
let deadline = Date().addingTimeInterval(timeout)

while Date() < deadline {
let n = read(fd, &readBuf, readBuf.count)
if n > 0 {
buffer.append(contentsOf: readBuf[0..<n])
if let newlineIndex = buffer.firstIndex(of: 0x0A) {
return Data(buffer[..<newlineIndex])
}
} else if n == 0 {
break
} else if errno != EAGAIN && errno != EINTR && errno != EWOULDBLOCK {
break
}
Thread.sleep(forTimeInterval: 0.01)
}

throw NSError(domain: "test", code: 4, userInfo: [NSLocalizedDescriptionKey: "Timeout reading raw line response"])
}

private func readMCPMessage(fd: Int32, timeout: TimeInterval = 5.0) throws -> [String: Any] {
return try readMCPMessages(fd: fd, expectedCount: 1, timeout: timeout).first ?? [:]
}
Expand Down
Loading