From 6f0410f9819c19f2a1e4f4d408022db2d33102b7 Mon Sep 17 00:00:00 2001 From: Baris Gulec <87657907+barissglc@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:28:23 +0200 Subject: [PATCH 1/2] Fix Codex initialize capability compatibility in CLI bridge --- CLI/MCPInitializeCompatibility.swift | 52 +++++++++ CLI/MCPServiceLoop.swift | 2 +- CLI/StdioProxy.swift | 21 +--- .../MCPInitializeCompatibilityTests.swift | 106 ++++++++++++++++++ iMCP.xcodeproj/project.pbxproj | 8 ++ 5 files changed, 171 insertions(+), 18 deletions(-) create mode 100644 CLI/MCPInitializeCompatibility.swift create mode 100644 CLITests/MCPInitializeCompatibilityTests.swift diff --git a/CLI/MCPInitializeCompatibility.swift b/CLI/MCPInitializeCompatibility.swift new file mode 100644 index 00000000..3a47ca17 --- /dev/null +++ b/CLI/MCPInitializeCompatibility.swift @@ -0,0 +1,52 @@ +import Foundation + +/// The bundled SDK cannot decode Codex's unused, object-valued auth-change capability. +enum MCPInitializeCompatibility { + static func normalize(_ data: Data) -> Data { + guard var request = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + request["jsonrpc"] as? String == "2.0", + request["method"] as? String == "initialize", + let id = request["id"], !(id is NSNull), + var params = request["params"] as? [String: Any], + var capabilities = params["capabilities"] as? [String: Any], + var experimental = capabilities["experimental"] as? [String: Any], + experimental["codex/auth-change"] is [String: Any] + else { return data } + + experimental.removeValue(forKey: "codex/auth-change") + if experimental.isEmpty { + capabilities.removeValue(forKey: "experimental") + } else { + capabilities["experimental"] = experimental + } + params["capabilities"] = capabilities + request["params"] = params + return (try? JSONSerialization.data(withJSONObject: request)) ?? data + } +} + +/// Stdin reads may split a JSON message or contain several newline-delimited messages. +struct MCPStdinFrames { + enum Failure: Error { case frameTooLarge } + + static let maximumFrameBytes = 10 * 1024 * 1024 + private var pending = Data() + + mutating func append(_ data: Data) throws -> [Data] { + pending.append(data) + var frames: [Data] = [] + while let newline = pending.firstIndex(of: 0x0A) { + guard pending.distance(from: pending.startIndex, to: newline) <= Self.maximumFrameBytes else { + throw Failure.frameTooLarge + } + let frame = Data(pending[.. 0 { - pendingData.append(contentsOf: buffer[0 ..< bytesRead]) - - let isOnlyWhitespace = pendingData.allSatisfy { - let char = Character(UnicodeScalar($0)) - return char.isWhitespace || char.isNewline - } - - if !isOnlyWhitespace && !pendingData.isEmpty { + for frame in try frames.append(Data(buffer[0 ..< bytesRead])) { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in connection.send( - content: pendingData, + content: frame, completion: .contentProcessed { error in if let error = error { continuation.resume(throwing: error) @@ -239,14 +232,8 @@ actor StdioProxy { ) } - await log.debug("Sent \(pendingData.count) bytes to network") - } else if isOnlyWhitespace && !pendingData.isEmpty { - await log.trace( - "Skipping send of \(pendingData.count) whitespace-only bytes" - ) + await log.debug("Sent \(frame.count) bytes to network") } - - pendingData.removeAll(keepingCapacity: true) } } catch let error as StdioProxyError { throw error diff --git a/CLITests/MCPInitializeCompatibilityTests.swift b/CLITests/MCPInitializeCompatibilityTests.swift new file mode 100644 index 00000000..3b0a5f50 --- /dev/null +++ b/CLITests/MCPInitializeCompatibilityTests.swift @@ -0,0 +1,106 @@ +import Foundation +import XCTest + +final class MCPInitializeCompatibilityTests: XCTestCase { + private let initialize = Data( + #"{"jsonrpc":"2.0","id":"request-1","method":"initialize","params":{"protocolVersion":"2025-06-18","clientInfo":{"name":"codex-mcp-client","version":"0.160.0"},"capabilities":{"elicitation":{"form":{}},"roots":{"listChanged":false},"experimental":{"codex/auth-change":{},"legacy":"unchanged"}}}}"# + .utf8 + ) + + func testRemovesOnlyUnsupportedCapability() throws { + let original = try XCTUnwrap(JSONSerialization.jsonObject(with: initialize) as? [String: Any]) + let normalized = try XCTUnwrap( + JSONSerialization.jsonObject(with: MCPInitializeCompatibility.normalize(initialize)) as? [String: Any] + ) + var expected = original + var params = try XCTUnwrap(expected["params"] as? [String: Any]) + var capabilities = try XCTUnwrap(params["capabilities"] as? [String: Any]) + capabilities["experimental"] = ["legacy": "unchanged"] + params["capabilities"] = capabilities + expected["params"] = params + XCTAssertEqual(normalized as NSDictionary, expected as NSDictionary) + } + + func testRemovesEmptyExperimentalMap() throws { + let input = Data( + #"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{"experimental":{"codex/auth-change":{}}}}}"# + .utf8 + ) + let result = try XCTUnwrap( + JSONSerialization.jsonObject(with: MCPInitializeCompatibility.normalize(input)) as? [String: Any] + ) + let params = try XCTUnwrap(result["params"] as? [String: Any]) + let capabilities = try XCTUnwrap(params["capabilities"] as? [String: Any]) + XCTAssertNil(capabilities["experimental"]) + } + + func testOtherRequestsStayByteIdentical() { + for method in ["tools/call", "tools/list", "notifications/initialized", "elicitation/create"] { + let input = Data( + String(decoding: initialize, as: UTF8.self) + .replacingOccurrences(of: "\"method\":\"initialize\"", with: "\"method\":\"\(method)\"").utf8 + ) + XCTAssertEqual(MCPInitializeCompatibility.normalize(input), input) + } + } + + func testMalformedAndUnrelatedDataStayByteIdentical() { + let inputs = [ + "not JSON", "[]", "null", "{\"method\":\"initialize\"}", + #"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{}}}"#, + #"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{"experimental":{"codex/auth-change":"legacy"}}}}"#, + #"{"jsonrpc":"2.0","id":null,"method":"initialize","params":{"capabilities":{"experimental":{"codex/auth-change":{}}}}}"#, + ] + for input in inputs { + let data = Data(input.utf8) + XCTAssertEqual(MCPInitializeCompatibility.normalize(data), data) + } + } + + func testUnknownCapabilitiesAreNotRemoved() { + let input = Data( + #"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{"experimental":{"other/auth-change":{}}}}}"# + .utf8 + ) + XCTAssertEqual(MCPInitializeCompatibility.normalize(input), input) + } + + func testFragmentedInitializeIsNormalizedOnceComplete() throws { + var frames = MCPStdinFrames() + for byte in initialize { XCTAssertTrue(try frames.append(Data([byte])).isEmpty) } + let result = try frames.append(Data([0x0A])) + XCTAssertEqual(result, [MCPInitializeCompatibility.normalize(initialize) + Data([0x0A])]) + } + + func testBatchedRequestsPreserveOrderAndOtherBytes() throws { + var frames = MCPStdinFrames() + let next = Data(#"{"jsonrpc":"2.0","id":2,"method":"tools/list"}"#.utf8) + let newline = Data([0x0A]) + let input = initialize + newline + next + newline + XCTAssertEqual( + try frames.append(input), + [MCPInitializeCompatibility.normalize(initialize) + newline, next + newline] + ) + } + + func testBlankLinesAndSplitUTF8() throws { + var frames = MCPStdinFrames() + XCTAssertTrue(try frames.append(Data(" \t\r\n\n".utf8)).isEmpty) + let input = Data(#"{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"şehir"}}"#.utf8) + for byte in input { XCTAssertTrue(try frames.append(Data([byte])).isEmpty) } + XCTAssertEqual(try frames.append(Data([0x0A])), [input + Data([0x0A])]) + } + + func testOversizedUnterminatedFrameFailsClosed() { + var frames = MCPStdinFrames() + XCTAssertThrowsError(try frames.append(Data(repeating: 0x61, count: MCPStdinFrames.maximumFrameBytes + 1))) + XCTAssertEqual(MCPServiceLoop.decision(error: MCPStdinFrames.Failure.frameTooLarge), .terminate) + } + + func testOversizedTerminatedFrameFailsClosed() { + var frames = MCPStdinFrames() + var input = Data(repeating: 0x61, count: MCPStdinFrames.maximumFrameBytes + 1) + input.append(0x0A) + XCTAssertThrowsError(try frames.append(input)) + } +} diff --git a/iMCP.xcodeproj/project.pbxproj b/iMCP.xcodeproj/project.pbxproj index 4fbcffdb..6600c824 100644 --- a/iMCP.xcodeproj/project.pbxproj +++ b/iMCP.xcodeproj/project.pbxproj @@ -30,6 +30,8 @@ F8D8C48E2DCE0E6800369E5C /* JSONSchema in Frameworks */ = {isa = PBXBuildFile; productRef = F8D8C48D2DCE0E6800369E5C /* JSONSchema */; }; F8F1D2F82D6F9E0C00F6323D /* MCP in Frameworks */ = {isa = PBXBuildFile; productRef = F8F44E9C2D5903F70075D79C /* MCP */; }; F9E9902DC72D90F6C423EFE2 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 48ABEE9E6903427DD79096A1 /* Logging */; }; + B0D1DEADBEEF00000000000A /* MCPInitializeCompatibility.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0D1DEADBEEF000000000009 /* MCPInitializeCompatibility.swift */; }; + B0D1DEADBEEF00000000000C /* MCPInitializeCompatibilityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B0D1DEADBEEF00000000000B /* MCPInitializeCompatibilityTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -65,6 +67,8 @@ E0E66768348853754D32208C /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.0.sdk/System/Library/Frameworks/Cocoa.framework; sourceTree = DEVELOPER_DIR; }; F8F44E6D2D59038D0075D79C /* iMCP.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = iMCP.app; sourceTree = BUILT_PRODUCTS_DIR; }; F8F44EB62D5908D00075D79C /* imcp-server */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.executable"; includeInIndex = 0; path = "imcp-server"; sourceTree = BUILT_PRODUCTS_DIR; }; + B0D1DEADBEEF000000000009 /* MCPInitializeCompatibility.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MCPInitializeCompatibility.swift; path = ../CLI/MCPInitializeCompatibility.swift; sourceTree = ""; }; + B0D1DEADBEEF00000000000B /* MCPInitializeCompatibilityTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MCPInitializeCompatibilityTests.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -150,6 +154,8 @@ B0D1DEADBEEF000000000003 /* BonjourDiscoveryTests.swift */, B0D1DEADBEEF000000000001 /* BonjourDiscovery.swift */, B0D1DEADBEEF000000000007 /* MCPServiceLoopTests.swift */, + B0D1DEADBEEF000000000009 /* MCPInitializeCompatibility.swift */, + B0D1DEADBEEF00000000000B /* MCPInitializeCompatibilityTests.swift */, B0D1DEADBEEF000000000005 /* MCPServiceLoop.swift */, ); path = CLITests; @@ -342,6 +348,8 @@ B0D1DEADBEEF000000000002 /* BonjourDiscovery.swift in Sources */, B0D1DEADBEEF000000000008 /* MCPServiceLoopTests.swift in Sources */, B0D1DEADBEEF000000000006 /* MCPServiceLoop.swift in Sources */, + B0D1DEADBEEF00000000000A /* MCPInitializeCompatibility.swift in Sources */, + B0D1DEADBEEF00000000000C /* MCPInitializeCompatibilityTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; From 23382cb3090593b9c7d2694ca599c08b68e718a2 Mon Sep 17 00:00:00 2001 From: Baris Gulec <87657907+barissglc@users.noreply.github.com> Date: Wed, 7 Oct 2026 14:13:11 +0200 Subject: [PATCH 2/2] Make normalized initialize JSON deterministic --- CLI/MCPInitializeCompatibility.swift | 2 +- CLITests/MCPInitializeCompatibilityTests.swift | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/CLI/MCPInitializeCompatibility.swift b/CLI/MCPInitializeCompatibility.swift index 3a47ca17..e5187697 100644 --- a/CLI/MCPInitializeCompatibility.swift +++ b/CLI/MCPInitializeCompatibility.swift @@ -21,7 +21,7 @@ enum MCPInitializeCompatibility { } params["capabilities"] = capabilities request["params"] = params - return (try? JSONSerialization.data(withJSONObject: request)) ?? data + return (try? JSONSerialization.data(withJSONObject: request, options: [.sortedKeys])) ?? data } } diff --git a/CLITests/MCPInitializeCompatibilityTests.swift b/CLITests/MCPInitializeCompatibilityTests.swift index 3b0a5f50..25ccedfa 100644 --- a/CLITests/MCPInitializeCompatibilityTests.swift +++ b/CLITests/MCPInitializeCompatibilityTests.swift @@ -21,6 +21,16 @@ final class MCPInitializeCompatibilityTests: XCTestCase { XCTAssertEqual(normalized as NSDictionary, expected as NSDictionary) } + func testNormalizedInitializeUsesStableSortedKeys() { + let expected = Data( + #"{"id":"request-1","jsonrpc":"2.0","method":"initialize","params":{"capabilities":{"elicitation":{"form":{}},"experimental":{"legacy":"unchanged"},"roots":{"listChanged":false}},"clientInfo":{"name":"codex-mcp-client","version":"0.160.0"},"protocolVersion":"2025-06-18"}}"# + .utf8 + ) + for _ in 0 ..< 100 { + XCTAssertEqual(MCPInitializeCompatibility.normalize(initialize), expected) + } + } + func testRemovesEmptyExperimentalMap() throws { let input = Data( #"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"capabilities":{"experimental":{"codex/auth-change":{}}}}}"#