diff --git a/brain-bar/Sources/BrainBar/BrainBarServer.swift b/brain-bar/Sources/BrainBar/BrainBarServer.swift index be85dc1..a547d19 100644 --- a/brain-bar/Sources/BrainBar/BrainBarServer.swift +++ b/brain-bar/Sources/BrainBar/BrainBarServer.swift @@ -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 @@ -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") + 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) diff --git a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift index 3272e4e..6032764 100644 --- a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift +++ b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift @@ -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] = [ @@ -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 diff --git a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift index 1ed0156..2a28175 100644 --- a/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift +++ b/brain-bar/Tests/BrainBarTests/SocketIntegrationTests.swift @@ -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" + ) } } @@ -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.. [String: Any] { return try readMCPMessages(fd: fd, expectedCount: 1, timeout: timeout).first ?? [:] }