From 45ac3a7c0d6df0d07ae0f209d5f8eaee23004f1b Mon Sep 17 00:00:00 2001 From: Test User Date: Sun, 3 May 2026 11:29:47 +0300 Subject: [PATCH] fix(brainbar): stabilize json-rpc envelope order --- .../Sources/BrainBar/BrainBarServer.swift | 2 +- brain-bar/Sources/BrainBar/MCPFraming.swift | 32 ++++++++++++++++++- .../Tests/BrainBarTests/MCPFramingTests.swift | 21 ++++++++++++ .../Tests/BrainBarTests/MCPRouterTests.swift | 18 +++++++++++ 4 files changed, 71 insertions(+), 2 deletions(-) diff --git a/brain-bar/Sources/BrainBar/BrainBarServer.swift b/brain-bar/Sources/BrainBar/BrainBarServer.swift index 729f49e1..be85dc16 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? JSONSerialization.data(withJSONObject: response) else { return false } + guard let jsonData = try? MCPFraming.encodeJSONResponse(response) else { return false } var data = jsonData data.append(0x0A) // trailing \n framed = data diff --git a/brain-bar/Sources/BrainBar/MCPFraming.swift b/brain-bar/Sources/BrainBar/MCPFraming.swift index 7864625f..223c51ff 100644 --- a/brain-bar/Sources/BrainBar/MCPFraming.swift +++ b/brain-bar/Sources/BrainBar/MCPFraming.swift @@ -113,15 +113,45 @@ struct MCPFraming: Sendable { /// Encode a JSON-RPC response with Content-Length framing. static func encode(_ response: [String: Any]) throws -> Data { - let jsonData = try JSONSerialization.data(withJSONObject: response) + let jsonData = try encodeJSONResponse(response) let header = "Content-Length: \(jsonData.count)\r\n\r\n" var frame = Data(header.utf8) frame.append(jsonData) return frame } + /// Encode JSON-RPC envelopes with a deterministic top-level key order. + /// + /// Claude Desktop currently rejects otherwise-valid JSON-RPC objects when + /// Foundation serializes `result` or `id` before `jsonrpc`. + static func encodeJSONResponse(_ response: [String: Any]) throws -> Data { + guard let jsonrpc = response["jsonrpc"], + let id = response["id"], + response["result"] != nil || response["error"] != nil else { + return try JSONSerialization.data(withJSONObject: response) + } + + var data = Data("{\"jsonrpc\":".utf8) + data.append(try encodeJSONValue(jsonrpc)) + data.append(Data(",\"id\":".utf8)) + data.append(try encodeJSONValue(id)) + if let result = response["result"] { + data.append(Data(",\"result\":".utf8)) + data.append(try encodeJSONValue(result)) + } else if let error = response["error"] { + data.append(Data(",\"error\":".utf8)) + data.append(try encodeJSONValue(error)) + } + data.append(0x7D) // } + return data + } + // MARK: - Private + private static func encodeJSONValue(_ value: Any) throws -> Data { + try JSONSerialization.data(withJSONObject: value, options: [.fragmentsAllowed]) + } + private func parseContentLength(_ header: String) -> Int? { for line in header.split(separator: "\r\n") { let trimmed = line.trimmingCharacters(in: .whitespaces) diff --git a/brain-bar/Tests/BrainBarTests/MCPFramingTests.swift b/brain-bar/Tests/BrainBarTests/MCPFramingTests.swift index ff6ed6a7..cd9aa2c0 100644 --- a/brain-bar/Tests/BrainBarTests/MCPFramingTests.swift +++ b/brain-bar/Tests/BrainBarTests/MCPFramingTests.swift @@ -113,6 +113,27 @@ final class MCPFramingTests: XCTestCase { XCTAssertTrue(str.contains("protocolVersion")) } + func testEncodesJSONRPCEnvelopeWithJSONRPCFirst() throws { + let response: [String: Any] = [ + "id": 2, + "jsonrpc": "2.0", + "result": [ + "tools": [ + ["name": "brain_search"] + ] + ] as [String: Any] + ] + + let framed = try MCPFraming.encode(response) + let frame = try XCTUnwrap(String(data: framed, encoding: .utf8)) + let body = try XCTUnwrap(frame.components(separatedBy: "\r\n\r\n").last) + + XCTAssertTrue( + body.hasPrefix(#"{"jsonrpc":"2.0","id":2,"result":"#), + "Claude Desktop expects the JSON-RPC envelope to serialize with jsonrpc first; got: \(body.prefix(80))" + ) + } + // MARK: - Empty body func testRejectsZeroContentLength() { diff --git a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift index 6a31b77a..3272e4ea 100644 --- a/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift +++ b/brain-bar/Tests/BrainBarTests/MCPRouterTests.swift @@ -140,6 +140,24 @@ final class MCPRouterTests: XCTestCase { XCTAssertEqual(toolNames, expected) } + func testEncodedToolsListEnvelopeStartsWithJSONRPC() throws { + let router = MCPRouter() + let response = router.handle([ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/list", + ]) + + let framed = try MCPFraming.encode(response) + let frame = try XCTUnwrap(String(data: framed, encoding: .utf8)) + let body = try XCTUnwrap(frame.components(separatedBy: "\r\n\r\n").last) + + XCTAssertTrue( + body.hasPrefix(#"{"jsonrpc":"2.0","id":2,"result":"#), + "Claude Desktop expects jsonrpc to be the first envelope key for tools/list; got: \(body.prefix(80))" + ) + } + func testEachToolHasInputSchema() throws { let router = MCPRouter() let request: [String: Any] = [